Loading content...
If there are two characteristics that define value objects above all others, they are immutability and value-based equality. These aren't merely implementation details—they are fundamental properties that derive directly from what value objects are.
Immutability means that once a value object is created, it never changes. A Money object representing $50.00 USD will always represent $50.00 USD. If you need $60.00, you create a new instance; you don't modify the existing one.
Value-based equality means that two value objects are considered equal if and only if all their attributes are equal. A $50.00 USD is equal to any other $50.00 USD, regardless of where or when those objects were created.
These properties aren't arbitrary design choices—they're logical necessities that flow from the very nature of value objects. Understanding why these properties matter is essential for implementing value objects correctly and avoiding subtle, hard-to-debug errors.
By the end of this page, you will understand why immutability is non-negotiable for value objects, how to implement proper value-based equality (including hashCode), and the dangerous bugs that emerge when these principles are violated. You'll also learn patterns for 'modification' operations on immutable objects.
Immutability isn't just a "nice to have" for value objects—it's a fundamental requirement that derives from their very definition. Let's examine why.
Conceptual Argument:
If a value object has no identity, what would it mean for it to change? Without identity, there's no sense in which a changed object is the "same object" that existed before the change. Entities can change because their identity persists across state changes. Value objects have no such persistent identity.
Consider the number 5. Can 5 change to become 6? No—5 is always 5. If you want 6, you have a different number, not a changed version of 5. Value objects work the same way.
Think of value objects like numbers. The number 42 doesn't change. If you want 43, you get a different number. Similarly, a Money object of $100 doesn't change. If you want $101, you create a new Money object. Numbers are the original value objects.
Practical Arguments for Immutability:
What happens when we violate immutability? Let's examine concrete examples of bugs that emerge from mutable value objects.
One of the most insidious bugs occurs when multiple references share a mutable value object:
1234567891011121314151617181920212223242526272829303132333435
// ❌ BAD: Mutable value object creates aliasing bugsclass MutableMoney { private amount: number; private currency: Currency; constructor(amount: number, currency: Currency) { this.amount = amount; this.currency = currency; } // DANGER: Mutation method! addAmount(addition: number): void { this.amount += addition; } getAmount(): number { return this.amount; }} // The bug in actionconst originalPrice = new MutableMoney(100, Currency.USD);const order = new Order();order.setPrice(originalPrice); // Later, somewhere else in the code...// Developer thinks they're creating a "discounted price"const discountedPrice = originalPrice;discountedPrice.addAmount(-20); // Oops! // Surprise! The order's price also changed!console.log(order.getPrice().getAmount()); // 80, not 100! // The order was never explicitly modified, yet its price changed.// This is an aliasing bug caused by mutable value objects.Aliasing bugs are among the hardest to debug because the mutation happens far from where the effect is observed. You'll stare at the Order class for hours wondering how the price changed, never thinking to look at code that modified a 'discounted price' variable.
Mutable objects used as hash map keys cause catastrophic bugs:
123456789101112131415161718192021222324252627282930313233343536373839
// ❌ BAD: Mutable value object as Map keyclass MutableCoordinates { private x: number; private y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } setX(x: number): void { this.x = x; } // Hash code based on mutable state hashCode(): number { return this.x * 31 + this.y; } equals(other: MutableCoordinates): boolean { return this.x === other.x && this.y === other.y; }} // The disasterconst location = new MutableCoordinates(10, 20);const locationMap = new Map<MutableCoordinates, string>(); locationMap.set(location, "Important data");console.log(locationMap.get(location)); // "Important data" ✓ // Now mutate the keylocation.setX(999); // Data is LOST! The hash code changed, so lookup failsconsole.log(locationMap.get(location)); // undefined! 😱 // Even worse: the data is still IN the map, but unreachable!// This is a memory leak AND a logic bug.Mutable value objects shared across threads create race conditions:
123456789101112131415161718192021222324252627282930313233343536373839
// ❌ BAD: Mutable value object shared across threadspublic class MutableDateRange { private LocalDate start; private LocalDate end; public MutableDateRange(LocalDate start, LocalDate end) { this.start = start; this.end = end; } public void setStart(LocalDate start) { this.start = start; } public void setEnd(LocalDate end) { this.end = end; } public boolean contains(LocalDate date) { // Race condition: another thread might change start/end // between these two comparisons! return !date.isBefore(start) && !date.isAfter(end); }} // Shared across threadsMutableDateRange activeWindow = new MutableDateRange( LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 31)); // Thread 1: updating the windowexecutor.submit(() -> { activeWindow.setStart(LocalDate.of(2025, 1, 1)); activeWindow.setEnd(LocalDate.of(2025, 12, 31));}); // Thread 2: checking if date is in windowexecutor.submit(() -> { // May see start=2025-01-01 but end=2024-12-31 (inconsistent state!) // Or may see start=2024-01-01 and end=2025-12-31 (invalid range!) boolean isValid = activeWindow.contains(LocalDate.of(2024, 6, 15)); // Result is unpredictable!});Creating truly immutable value objects requires attention to several details. Here are the essential techniques:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ✅ CORRECT: Truly immutable value objectclass DateRange { // 1. Readonly fields private readonly start: Date; private readonly end: Date; private constructor(start: Date, end: Date) { // Validation if (start > end) { throw new Error("Start must be before or equal to end"); } // 2. Defensive copies on input // Date is mutable, so we copy it this.start = new Date(start.getTime()); this.end = new Date(end.getTime()); } static between(start: Date, end: Date): DateRange { return new DateRange(start, end); } // 3. NO setter methods - not even private ones // 4. Defensive copies on output getStart(): Date { return new Date(this.start.getTime()); } getEnd(): Date { return new Date(this.end.getTime()); } // 5. Operations return NEW instances extend(days: number): DateRange { const newEnd = new Date(this.end); newEnd.setDate(newEnd.getDate() + days); return new DateRange(this.start, newEnd); } shift(days: number): DateRange { const newStart = new Date(this.start); const newEnd = new Date(this.end); newStart.setDate(newStart.getDate() + days); newEnd.setDate(newEnd.getDate() + days); return new DateRange(newStart, newEnd); } // Query methods are always safe - they don't modify anything contains(date: Date): boolean { return date >= this.start && date <= this.end; } getDurationInDays(): number { const diffMs = this.end.getTime() - this.start.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; }} // Usageconst q1 = DateRange.between( new Date('2024-01-01'), new Date('2024-03-31')); // "Modify" returns a new instance; original is unchangedconst q2 = q1.shift(91); // Q2 console.log(q1.getStart()); // Still Jan 1, 2024console.log(q2.getStart()); // Apr 1, 2024The most common immutability mistake is storing references to mutable objects (Date, arrays, collections) without copying them. Always make defensive copies of mutable inputs and outputs. Better yet, use immutable types (LocalDate, immutable collections) to eliminate the need for copying.
Value objects must implement equality based on their attribute values, not object identity. Two value objects are equal if and only if all their component values are equal.
This is fundamentally different from entities, which compare by identity (ID), and from default object equality, which compares by reference.
| Type | Equality Based On | Example |
|---|---|---|
| Reference Equality | Same object in memory | a === b (same pointer) |
| Identity Equality | Same ID, ignoring other fields | a.id === b.id |
| Value Equality | All attributes equal | a.amount === b.amount && a.currency === b.currency |
When implementing value equality, you must satisfy the equality contract. This contract ensures that equality behaves consistently and predictably:
x.equals(x) must be true.x.equals(y), then y.equals(x). Equality is bidirectional.x.equals(y) and y.equals(z), then x.equals(z).x.equals(y) return the same result (assuming no mutations, which we've prevented via immutability).x.equals(null) must return false, never throw.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
class Money { private readonly amount: number; private readonly currency: Currency; constructor(amount: number, currency: Currency) { this.amount = amount; this.currency = currency; } equals(other: unknown): boolean { // Handle null/undefined if (other === null || other === undefined) { return false; } // Handle type mismatch if (!(other instanceof Money)) { return false; } // Compare all value components return this.amount === other.amount && this.currency === other.currency; } // Hash code must be consistent with equals hashCode(): number { let hash = 17; // Use same fields as equals() hash = hash * 31 + Math.floor(this.amount * 100); hash = hash * 31 + this.currency.hashCode(); return hash; }} // Verifying the equality contractconst m1 = new Money(100, Currency.USD);const m2 = new Money(100, Currency.USD);const m3 = new Money(100, Currency.USD);const m4 = new Money(50, Currency.USD); // Reflexiveconsole.log(m1.equals(m1)); // true // Symmetricconsole.log(m1.equals(m2)); // trueconsole.log(m2.equals(m1)); // true // Transitiveconsole.log(m1.equals(m2)); // trueconsole.log(m2.equals(m3)); // trueconsole.log(m1.equals(m3)); // true // Consistentconsole.log(m1.equals(m2)); // trueconsole.log(m1.equals(m2)); // true (same result) // Null handlingconsole.log(m1.equals(null)); // false (no exception) // Unequal valuesconsole.log(m1.equals(m4)); // falseWhenever you implement equals(), you must also implement hashCode() correctly. These two methods are bound by a strict contract, and violating it causes catastrophic bugs in hash-based collections (HashMap, HashSet, Dictionary, etc.).
a.equals(b) is true, then a.hashCode() MUST equal b.hashCode().If two objects are equal according to equals(), they MUST have the same hash code. Violating this rule breaks HashMap, HashSet, and any hash-based collection. Objects will be 'lost' in these collections just like we saw with mutable keys.
Use the same fields in hashCode() that you use in equals():
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
class Address { private readonly street: string; private readonly city: string; private readonly postalCode: string; private readonly country: string; // ... constructor ... equals(other: Address): boolean { if (!(other instanceof Address)) return false; return this.street === other.street && this.city === other.city && this.postalCode === other.postalCode && this.country === other.country; } // hashCode uses SAME fields as equals hashCode(): number { let hash = 17; hash = hash * 31 + this.stringHashCode(this.street); hash = hash * 31 + this.stringHashCode(this.city); hash = hash * 31 + this.stringHashCode(this.postalCode); hash = hash * 31 + this.stringHashCode(this.country); return hash; } private stringHashCode(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash; }} // ❌ BAD: Using subset of fields in hashCodeclass BadAddress { equals(other: BadAddress): boolean { return this.street === other.street && this.city === other.city && this.postalCode === other.postalCode && this.country === other.country; } hashCode(): number { // WRONG: Only uses street, not all fields! // Two addresses with same street but different cities // will have same hashCode but equals() returns false // This VIOLATES the contract (but in permissible direction) // However, it causes terrible hash table performance return this.stringHashCode(this.street); }} // ❌ WORSE: Using different fields in hashCodeclass WrongAddress { private id: number; // NOT used in equals! equals(other: WrongAddress): boolean { // equals uses value fields return this.street === other.street && this.city === other.city; } hashCode(): number { // WRONG: Uses id which is NOT part of equals! // Two equal addresses may have DIFFERENT hash codes // This VIOLATES the contract catastrophically! return this.id; }}How do we "modify" immutable objects? We don't—we create new objects with the desired changes. This pattern is variously called copy-on-write, with methods, or the builder pattern for complex objects.
The most common approach uses methods prefixed with with that return new instances:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
class Person { private readonly firstName: string; private readonly lastName: string; private readonly email: Email; private constructor( firstName: string, lastName: string, email: Email ) { this.firstName = firstName; this.lastName = lastName; this.email = email; } static create(firstName: string, lastName: string, email: Email): Person { return new Person(firstName, lastName, email); } // "With" methods create new instances with one field changed withFirstName(firstName: string): Person { return new Person(firstName, this.lastName, this.email); } withLastName(lastName: string): Person { return new Person(this.firstName, lastName, this.email); } withEmail(email: Email): Person { return new Person(this.firstName, this.lastName, email); } // Multiple changes via chaining withName(firstName: string, lastName: string): Person { return new Person(firstName, lastName, this.email); }} // Usage - immutable updates through method chainingconst original = Person.create("Alice", "Smith", Email.of("alice@old.com")); // Create a modified copy - original is unchangedconst updated = original .withLastName("Johnson") .withEmail(Email.of("alice@new.com")); console.log(original.getLastName()); // "Smith" (unchanged)console.log(updated.getLastName()); // "Johnson" (new instance)When value objects have many fields, consider a builder:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
class Address { readonly street: string; readonly city: string; readonly state: string; readonly postalCode: string; readonly country: string; readonly apartment?: string; readonly buildingName?: string; private constructor(builder: AddressBuilder) { this.street = builder.street; this.city = builder.city; this.state = builder.state; this.postalCode = builder.postalCode; this.country = builder.country; this.apartment = builder.apartment; this.buildingName = builder.buildingName; } static builder(): AddressBuilder { return new AddressBuilder(); } // Create a builder pre-populated with this address's values toBuilder(): AddressBuilder { return new AddressBuilder() .setStreet(this.street) .setCity(this.city) .setState(this.state) .setPostalCode(this.postalCode) .setCountry(this.country) .setApartment(this.apartment) .setBuildingName(this.buildingName); }} class AddressBuilder { street: string = ''; city: string = ''; state: string = ''; postalCode: string = ''; country: string = ''; apartment?: string; buildingName?: string; setStreet(street: string): this { this.street = street; return this; } setCity(city: string): this { this.city = city; return this; } setState(state: string): this { this.state = state; return this; } setPostalCode(postalCode: string): this { this.postalCode = postalCode; return this; } setCountry(country: string): this { this.country = country; return this; } setApartment(apartment?: string): this { this.apartment = apartment; return this; } setBuildingName(buildingName?: string): this { this.buildingName = buildingName; return this; } build(): Address { // Validation if (!this.street || !this.city || !this.postalCode || !this.country) { throw new Error("Required fields missing"); } return new Address(this); }} // Usageconst home = Address.builder() .setStreet("123 Main St") .setCity("New York") .setState("NY") .setPostalCode("10001") .setCountry("USA") .setApartment("4B") .build(); // Create modified copy using builderconst newHome = home.toBuilder() .setStreet("456 Oak Ave") .setApartment("7C") .build();Even experienced developers make mistakes when implementing equality. Here are the most common pitfalls:
1234567891011121314151617
// ❌ BAD: Missing null/type checksclass BadMoney { equals(other: BadMoney): boolean { // Crashes if other is null or wrong type! return this.amount === other.amount; }} // ✅ GOOD: Proper null and type handlingclass GoodMoney { equals(other: unknown): boolean { if (other === null || other === undefined) return false; if (!(other instanceof GoodMoney)) return false; return this.amount === other.amount && this.currency === other.currency; }}123456789101112131415161718192021222324252627282930313233
// ❌ BAD: Direct floating point comparisonclass BadMeasurement { constructor(private readonly value: number) {} equals(other: BadMeasurement): boolean { // Floating point imprecision causes false negatives! return this.value === other.value; }} const m1 = new BadMeasurement(0.1 + 0.2);const m2 = new BadMeasurement(0.3);console.log(m1.equals(m2)); // false! (0.30000000000000004 !== 0.3) // ✅ GOOD: Use epsilon for floating point, or avoid floats for moneyclass GoodMeasurement { private static readonly EPSILON = 0.0000001; constructor(private readonly value: number) {} equals(other: GoodMeasurement): boolean { return Math.abs(this.value - other.value) < GoodMeasurement.EPSILON; }} // ✅ BETTER: Use integers for money (cents, not dollars)class BetterMoney { constructor(private readonly cents: number) {} // Integer! equals(other: BetterMoney): boolean { return this.cents === other.cents; // Integer comparison is safe }}123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ BAD: instanceof allows subclass comparisonclass Point { protected final int x, y; @Override public boolean equals(Object obj) { if (!(obj instanceof Point other)) return false; return x == other.x && y == other.y; }} class ColoredPoint extends Point { private final Color color; @Override public boolean equals(Object obj) { if (!(obj instanceof ColoredPoint other)) return false; return super.equals(obj) && color.equals(other.color); }} // The problem: symmetry is violated!Point p = new Point(1, 2);ColoredPoint cp = new ColoredPoint(1, 2, Color.RED); p.equals(cp); // true (Point instanceof check passes)cp.equals(p); // false (ColoredPoint instanceof fails) // This violates the symmetry requirement! // ✅ SOLUTION: Use getClass() instead of instanceofclass CorrectPoint { @Override public boolean equals(Object obj) { if (obj == null || getClass() != obj.getClass()) return false; CorrectPoint other = (CorrectPoint) obj; return x == other.x && y == other.y; }} // ✅ BETTER: Make value objects final (prevent subclassing)public final class Point { ... }We've explored the twin pillars that define value objects. Let's consolidate the key insights:
What's next:
With a solid understanding of immutability and equality, we're ready to explore value object design in depth. The next page covers how to design value objects that are expressive, composable, and truly useful for modeling domain concepts.
You now understand why immutability and value-based equality are essential characteristics of value objects, how to implement them correctly, and the dangerous bugs that emerge when they're violated. Next, we'll learn how to design value objects that elegantly capture domain concepts.