Module 01 · Object thinking · Lesson 4 of 4
Immutability and value objects
Small immutable types like Money, Email and DateRange that replace primitives and carry their own rules. Value objects versus entities, how immutability removes classes of bugs, and changing and persisting immutable objects.
Why it matters
A surprising share of bugs come from passing raw primitives around: an amount in dollars treated as cents, a user id passed where an order id was expected, an email that was never validated, a date range whose end is before its start. Each primitive carries no rules, so every function must remember to check them, and some do not. Small immutable value objects put those rules in one place and make whole categories of mistakes impossible to write.
The idea
A value object is defined entirely by its values: two Money(10, EUR) objects are interchangeable, and it does not matter which one you hold. An entity has an identity that persists while its attributes change: a Customer who changes address is still the same customer. Value objects should be immutable: once created, they never change; operations return new values.
| Value object | Entity | |
|---|---|---|
| Defined by | Its attribute values | A stable identity (an id) |
| Equality | All attributes equal | Same id |
| Mutability | Immutable; operations return new values | Attributes change over its lifecycle |
| Lifecycle | None; created and discarded freely | Created, changed, archived, often persisted with history |
| Examples | Money, Email, Address, DateRange, Coordinates, Quantity | Customer, Order, Account, Booking |
Replacing primitive obsession
Primitive obsession is modelling domain concepts as strings, ints and doubles. Replacing them with value objects gives each concept validation at construction, operations that respect its rules, and type safety that stops mix-ups at compile time.
public record Money(long minorUnits, Currency currency) {
public Money { // compact constructor: validate once
Objects.requireNonNull(currency);
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(Math.addExact(minorUnits, other.minorUnits), currency);
}
public Money times(int quantity) { return new Money(Math.multiplyExact(minorUnits, quantity), currency); }
}
public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
if (end.isBefore(start)) throw new IllegalArgumentException("end before start");
}
public boolean overlaps(DateRange o) { return !end.isBefore(o.start) && !o.end.isBefore(start); }
}
Now Money cannot be added across currencies, a DateRange cannot be backwards, and a method that takes (CustomerId, OrderId) cannot be called with the arguments swapped.
Why immutability pays
An immutable object cannot be put into an invalid state after construction, so its invariants hold forever. It can be shared between variables, caches and threads without copying or locking, because nobody can change it under you. It is safe as a hash map key. And reasoning about code gets easier: a value you were handed is the value you have. The cost is creating a new object for each change, which modern runtimes handle cheaply for small objects.
Changing an immutable object
Operations return new instances: price.plus(tax), range.extendTo(date). For objects with many fields, a wither (address.withPostcode(p)) copies all fields but one, and a builder assembles a complex value step by step before constructing it once. Language features help: Java records, Kotlin data classes with copy, C# records with with expressions, Python frozen dataclasses with replace.
Entities hold value objects
Entities are where change happens, and they are made of value objects. An Order (entity, mutable status, identity) holds OrderLine values, a Money total and an Address snapshot. Changing the shipping address replaces the Address value inside the order; the order keeps its identity. This split keeps mutation confined to a few entities and makes most of the model safe to share and test.
Persisting value objects
Value objects usually have no table of their own: they are stored as columns of the owning entity (amount and currency columns), as an embedded JSON field, or flattened by the ORM's embeddable mechanism. Reconstitute them through the same constructor so stored data passes the same validation as new data, or through a trusted factory when loading historical data that predates a rule.
Trade-offs
Value objects add small types and a mapping step at the edges (JSON, database, framework binding), and naive use can create many short-lived objects in hot paths. In exchange, validation lives in one place, mix-ups fail at compile time and shared state stops being a source of bugs. Reserve them for concepts with rules or a risk of confusion; a plain int count with no rules needs no wrapper.
In an interview
Show value objects naturally in your model: "Seat prices are Money, so currency mismatches are impossible; a Show has a DateRange that cannot be backwards; customer and booking ids are distinct types so they cannot be swapped. Booking is the entity with identity and status; everything inside it is immutable." It signals mature modelling without any lecture.
Check yourself
A method transfer(long fromAccount, long toAccount, double amount) has been called with the account ids swapped and with amounts in dollars where cents were expected. Which redesign prevents both mistakes at compile time?