Low-level designCore6 min

Value objects, equality and immutability

Some objects are things with an identity; others are values, like a time slot or an amount, where two equal ones are interchangeable. Give values equality by their contents and make them immutable, and two classic bugs disappear: duplicates that aren't noticed, and changes that leak through a shared object.

Watch it evolve1 / 7
The problem

A room booked by time slot

Meetings have time slots, and the room refuses a slot that's already booked.

80
Start
Stage 1
Final

1 · The situation

Booking a meeting room

A coworking space books its meeting room by time slot. A TimeSlot holds a start and an end, in minutes after midnight; a Meeting has a title and a slot; the Room keeps the slots already booked and refuses a slot that's taken. The first version makes TimeSlot an ordinary class that can be moved:

A slot that can move, and a room that checks for duplicatesJava
class TimeSlot {
    // Minutes after midnight.
    private int start;
    private int end;

    TimeSlot(int start, int end) {
        this.start = start;
        this.end = end;
    }

    void moveBy(int minutes) {
        start += minutes;
        end += minutes;
    }

    String text() {
        return String.format("%02d:%02d-%02d:%02d", start / 60, start % 60, end / 60, end % 60);
    }
}

class Room {
    private final List<TimeSlot> booked = new ArrayList<>();

    boolean book(TimeSlot slot) {
        if (booked.contains(slot)) return false;
        booked.add(slot);
        return true;
    }
}
*TimeSlot− start: int− end: int~ moveBy(int)~ text(): StringMeeting− title: String− slot: TimeSlot~ slot(): TimeSlot~ text(): StringRoom− booked: List<TimeSlot>~ book(TimeSlot): boolean

Drawn from the code: meetings and the room both refer to TimeSlot objects.

2 · The change

A double booking, and a meeting that moved by itself

Two people book 09:00 to 09:15. Then a pairing session is set up "at the same time as standup", by passing standup's slot, and later moved half an hour on:

09:00-09:15 booked
09:00-09:15 booked
Standup at 09:30-09:45
Pairing at 09:30-09:45

The second booking of the same slot went through, and moving the pairing session moved standup too.

Two different mistakes, both about what a time slot is:

  • Equality by identity. The room asks "is this slot already booked?", and the list answers by comparing objects, not times. Two separately created 09:00 to 09:15 slots are different objects, so the duplicate slips through.
  • A shared, changeable object. Both meetings hold the same slot object. Moving it for one meeting moves it for both, from code that never mentioned standup.

3 · The fix

Make the slot a value

A time slot has no identity worth keeping: any 09:00 to 09:15 is as good as any other. Model it as a value object: equal when its contents are equal, and never changed after it's made.

In Java a record compares its fields in equals() and hashCode(); Python's frozen dataclass does the same, and C++ asks for a defaulted comparison. The room keeps a set of slots, so a second 09:00 to 09:15 is recognized as already there.

Java
record TimeSlot(int start, int end) {
    TimeSlot {
        if (end <= start) throw new IllegalArgumentException("a slot must end after it starts");
    }

    TimeSlot movedBy(int minutes) {
        return new TimeSlot(start + minutes, end + minutes);
    }

    String text() {
        return String.format("%02d:%02d-%02d:%02d", start / 60, start % 60, end / 60, end % 60);
    }
}

class Room {
    private final Set<TimeSlot> booked = new HashSet<>();

    boolean book(TimeSlot slot) {
        return booked.add(slot);
    }
}
1*«record»TimeSlot− start: int− end: int~ movedBy(int): TimeSlot~ text(): StringMeeting− title: String− slot: TimeSlot~ reschedule(TimeSlot)~ slot(): TimeSlot~ text(): StringRoom− booked: Set<TimeSlot>~ book(TimeSlot): boolean
09:00-09:15 booked
09:00-09:15 refused, already booked
Standup at 09:00-09:15
Pairing at 09:30-09:45

The duplicate is refused, and standup stays at 09:00.

4 · Values and entities

Is it a value, or a thing with an identity?

Aspect

Value object

Entity

Two with the same contents

Are the same value: interchangeable

Are still two things

Equality

By contents

By identity, or by an id

Changes

Never; make a new one

Over its life, through its methods

Examples

A time slot, an amount of money, a date, a color

A meeting, a booking, a member, a gift card

The meeting stays an entity: two meetings at the same time with the same title are still two meetings. It holds a value, and changing its time means replacing that value. The page Classes, objects and state is about entities; this page is about the other half.

1*«record»TimeSlot− start: int− end: int~ movedBy(int): TimeSlot~ text(): StringMeeting− title: String− slot: TimeSlot~ reschedule(TimeSlot)~ slot(): TimeSlot~ text(): StringRoom− booked: Set<TimeSlot>~ book(TimeSlot): boolean

After the fix: TimeSlot is a record with no way to change it; Meeting holds one and replaces it to reschedule.

5 · Equality

Equal values must hash alike

Hash-based collections, a HashSet or a Python set, find an object by its hash first and its equality second. So a class that defines equality by contents must define its hash from the same contents: two equal slots with different hashes would land in different buckets, and the duplicate would slip through again. Java records and Python frozen dataclasses generate both together; written by hand, equals() and hashCode() must always change together.

Language

Value equality

Immutability

Java

A record generates equals() and hashCode() from its fields

Record fields are final; validate in the compact constructor

C++

operator<=> and operator== can be defaulted (C++20)

Values are copied by default; offer no setters

Python

@dataclass generates __eq__; with frozen=True, also __hash__

frozen=True: assigning a field raises an error

JavaScript

None for objects: === compares identity, so compare by a key

Object.freeze(this) stops changes to its own fields

6 · Immutability

Why never changing is worth it

  • Safe to share. An immutable slot can be passed to any number of meetings, as the fixed version does; nobody can change it under the others.
  • Safe in sets and maps. A value whose contents changed after it went into a hash set would sit in the wrong bucket, invisible to its own lookups.
  • Validated once. The constructor checks that a slot ends after it starts, and that stays true forever.
  • Safe across threads, since there's nothing to update; the concurrency pages build on that.

The cost is a new object for every change. For small values like slots and amounts that's cheap and usually invisible. Immutability can also be shallow: a Java record holding a list still lets that list change, so a value object should hold only values, or copies of collections.

7 · In practice

Values in the garage

In the parking-lot problem, a spot size, a license plate and a fee amount are values; a ticket and a spot are entities. Make the values immutable with equality by contents, and the entities hold them.

Design the parking lot with its values and its entities apart.

Check yourself

4 questions

1. Why did the room accept 09:00 to 09:15 twice?
2. Moving the pairing session also moved standup. Why?
3. A class defines equals() by contents but keeps the default hashCode(). What can go wrong in a HashSet?
4. Which is better modeled as a value object?

Takeaways

Remember this

  • A value object is defined by its contents: two equal ones are interchangeable.
  • Give values equality by contents, and a hash that agrees with it.
  • Make values immutable: a change makes a new value, and the holder takes it.
  • A shared mutable object leaks changes to every holder; an immutable one can be shared freely.
  • Entities have identities and change over time; they hold values.