Concepts / Object-oriented foundations / Value objects and immutability

Value objects and immutability

Objects defined by their value, never changed, safely shared.

01The intuition

The problem it solves

A small program keeps track of pocket money. Amounts are plain floats, and dates are strings, and both are passed around freely.

def split_bill(total: float, people: int) -> float:
    return total / people

share = split_bill(100.0, 3)      # 33.333333333333336
print(share * 3)                  # 100.00000000000001

budget = {"food": 40.0}
spent = budget["food"]
spent -= 10                       # budget["food"] is still 40, confusing
dates = ["2026-1-5", "05/01/2026"]  # the same day, two spellings

Three bugs are hiding here. Money with floats does not add up to the same total. A number copied out of a dictionary silently becomes a different number, and a reader cannot tell whether that was the intent. And two values that mean the same thing compare as different because they are only strings. The program has concepts, money and dates, that it never named.

A value object is a small class for a concept like money, a date, a point or a card, where two instances are equal when their contents are equal, and where nothing can change after creation. Immutability means that once you hold one, it stays what it was.

Build it one piece at a time

Step 1: give the concept a name and store it exactly. Money becomes a class holding whole paise, so adding never loses a fraction.

class Money:
    def __init__(self, paise: int, currency: str = "INR"):
        self.paise = paise
        self.currency = currency

Step 2: define equality by value. Two hundred rupees is two hundred rupees wherever it came from, so == and hashing must look at the contents, not at the memory address.

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    paise: int
    currency: str = "INR"

assert Money(20000) == Money(20000)
assert len({Money(1), Money(1)}) == 1

Step 3: forbid change. frozen=True makes assignment raise, so an operation returns a new value instead of editing the old one. Nobody can be surprised by a shared object changing under them.

@dataclass(frozen=True)
class Money:
    paise: int
    currency: str = "INR"

    def plus(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("currency mismatch")
        return Money(self.paise + other.paise, self.currency)

    def split(self, parts: int) -> list["Money"]:
        base, extra = divmod(self.paise, parts)
        return [Money(base + (1 if i < extra else 0), self.currency) for i in range(parts)]

Step 4: push the rules into the value. split distributes the remainder paise one by one, so three shares of 100 rupees add back to exactly 100 rupees. The rule lives once, in the type, instead of in every caller.

StepMoney(100).split(3) sums toCan a shared amount changeEqual amounts compare equal
Start (float)100.00000000000001yes, silentlyonly if bit-identical
1exact, but by handyesno (identity)
2exact, by handnoyes
3 and 4exact, by the typenoyes

Why it works

An immutable value has one invariant: it is valid at construction and never becomes invalid afterwards, because nothing can edit it. That removes a whole class of bugs, the ones where object A and object B both hold the same amount and one of them changes it. It also makes values safe to use as dictionary keys and set members, since a key that changed after insertion would be lost, and safe to share between threads without locks, because there is nothing to race on.

Equality by value is what lets you write tests like assert bill.total() == Money(30000) and lets a parser produce the same Date for two spellings of one day. Where it breaks: a value object that holds a mutable list inside is not immutable, so store tuples; and a very large object copied on every change costs memory, which is where a persistent structure or a plain mutable entity is the better choice.

The trade-off you must say out loud

Not everything is a value. A bank account has identity: two accounts with the same balance are different accounts, and an account changes over time. That is an entity, and it should be mutable with a stable id. The sentence to say is "values are compared by content and never change; entities have an id and a history". The follow-up is often about performance, and the answer is that creating a small frozen object costs about as much as a tuple, which is nothing next to the bugs it prevents.

Where it shows up in the 100 Google problems

  • Design Splitwise: Money with exact splitting is the difference between balances that settle to zero and balances that are off by a paisa.
  • Design a deck of cards and Blackjack: a Card(rank, suit) is a frozen value, so a hand can be a set and a shuffled deck is a new list, never an edited card.
  • Design calendar event conflict handling: a TimeRange(start, end) value with an overlaps method keeps the comparison rule in one place.
  • Design an order matching engine: prices and quantities are exact integers in value objects; a fill produces new values rather than mutating an order another thread is reading.
  • Design 2048: a board as an immutable tuple of tuples makes undo a matter of keeping the previous board.

02Used in these problems