Primitive Obsession: replace bare strings and floats with value objects

Bare strings and floats let wrong values travel anywhere. Learn how small value objects for money and email add rules and remove a class of bugs.

Loose primitive values wrapped into one value object

Primitive Obsession means using bare strings, numbers and booleans for things that have meaning and rules: money, email addresses, phone numbers, dates, postal codes. A float cannot know that it is a price, and a string cannot know that it must contain an @. The code and output below were run with Python 3 before publishing.

How to recognise it

  • Money stored as a float, with a currency in a separate variable or nowhere.
  • Many if "@" in email checks scattered around, or none at all.
  • Magic strings such as "pending" or "BD" compared in many files.
  • Two values that must be used together (amount and currency) passed separately.
  • The same validation copied into several functions.

What can go wrong

Floats cannot represent most decimals exactly, so money arithmetic drifts. And nothing stops you from adding dollars to taka when both are just numbers. The first lines of the output below show both problems, then the fix.

The fix: replace the primitive with a small object

A value object wraps a primitive and carries its rules. Create it once, validate it once, and every function that receives it can trust it. A frozen dataclass makes it immutable and gives it equality for free.

from dataclasses import dataclass

# ---- BEFORE: money as a float, an email as a bare string ----
print("float sum:", 0.1 + 0.2, "| equals 0.3?", 0.1 + 0.2 == 0.3)
print("USD + BDT as plain numbers:", 10.0 + 1200.0)   # nonsense, and nothing stops it

# ---- AFTER: small value objects that know their own rules ----
@dataclass(frozen=True)
class Money:
    cents: int
    currency: str
    @classmethod
    def of(cls, amount: str, currency: str):
        whole, _, frac = amount.partition(".")
        return cls(int(whole) * 100 + int((frac + "00")[:2]), currency)
    def __add__(self, other):
        if other.currency != self.currency:
            raise ValueError(f"cannot add {other.currency} to {self.currency}")
        return Money(self.cents + other.cents, self.currency)
    def __str__(self):
        return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"

@dataclass(frozen=True)
class Email:
    value: str
    def __post_init__(self):
        if self.value.count("@") != 1 or " " in self.value or "." not in self.value.split("@")[1]:
            raise ValueError(f"not an email address: {self.value!r}")

Money stores integer cents, refuses to add different currencies and prints itself properly. Email refuses to be created with an invalid address, so any function that takes an Email knows it is already valid.

Real output

float sum: 0.30000000000000004 | equals 0.3? False
USD + BDT as plain numbers: 1210.0
0.30 USD
True
error: cannot add BDT to USD
amina@example.com
error: not an email address: 'amina.example.com'

The float sum prints 0.30000000000000004, while Money adds 0.10 and 0.20 to exactly 0.30 USD and the two compare equal. The mix of USD and BDT is now an error instead of a silently wrong number, and a bad email fails at the edge of the program rather than deep inside it.

How to refactor safely

  1. Pick one primitive that carries rules (start with money or email).
  2. Create the value object and test it alone.
  3. Replace the primitive at the boundaries first (where data enters), then work inward.
  4. Delete the scattered checks once the type makes them unnecessary.
  5. For a fixed set of strings such as statuses, use an Enum.

When to leave it alone

  • A plain int that really is just a count needs no wrapper.
  • Do not wrap every string. Wrap the ones with rules or units.
  • In real money code, consider Python's decimal.Decimal or a well-tested library, not a hand-written class, if you need rounding modes or many currencies.

Common mistakes

  • A wrapper with no validation, which adds typing but no safety.
  • Mutable value objects that change under your feet. Make them frozen.
  • Validating in the constructor in one place and again in every function anyway.
  • Storing money as a float "for now".

Main idea: if a value has rules, give it a type. Small value objects such as Money and Email move validation to one place and make wrong values impossible to pass around.


Code smells and refactoring series, Bloaters: 3 of 5. Previous: Large Class code smell: split a class that does too much.

Keep reading