If you've ever explored Domain-Driven Design, you've probably hit a wall somewhere around value objects. There's a folder in your domain layer, someone told you "don't modify them, replace them", and that was the end of the explanation.
It feels arbitrary. You can modify anything in your own code — so why this extra restriction?
This is a recurring problem with DDD: developers are handed a set of rules without being shown the problem those rules were designed to solve. So teams get frustrated, call it overengineering, and go back to MVC. Can't blame them.
Let's try a different approach. Instead of starting with the rule, let's start with the problem.
The Cow Farm
A client walks into a cow farm and asks to see some fine cows. The farmer presents one — almost perfect. After careful inspection, the client says:
"I need a cow with a hairier tail."
Two options:
- Detach the tail, find a new hairier one, reattach it.
- Detach the tail, add more hair to it, reattach it.
Both are obviously wrong. Not because of some rule — because it doesn't make sense. A cow isn't a LEGO set. You don't modify its parts in isolation. If the client wants a slightly different feature on a cow, you find a different cow.
This is the core idea. Some things in your domain simply don't make sense to partially modify. When that's the case, you replace the whole thing.
What This Looks Like in Code
Here's where most explanations jump to abstract examples. Let's be concrete.
Coordinates
A GPS location is defined by two values: latitude and longitude. Together, they point to a single place on Earth. Separately, they mean nothing.
// Mutable approach — dangerous
class Location {
latitude: number;
longitude: number;
}
const office = new Location();
office.latitude = 44.4268; // Bucharest
office.longitude = 26.1025;
// Later, someone "updates" the location
office.latitude = 48.8566; // Paris latitude...
// but longitude is still Bucharest
// You're now in the Carpathians, not ParisThe bug is silent. No error is thrown. The object looks valid. But the data is wrong — because one property was updated while the other wasn't.
A value object prevents this:
class Coordinates {
constructor(
readonly latitude: number,
readonly longitude: number
) {
if (latitude < -90 || latitude > 90) throw new Error("Invalid latitude");
if (longitude < -180 || longitude > 180) throw new Error("Invalid longitude");
}
moveTo(lat: number, lon: number): Coordinates {
return new Coordinates(lat, lon); // new object, both values validated together
}
}You can't have a half-updated location. Either you create a valid Coordinates object, or you get an error. There's no in-between state.
Date Range
A booking has a start date and an end date. Together, they form a range. The invariant is simple: end must be after start.
// Mutable — invariant can be violated
const booking = new Booking();
booking.startDate = new Date("2024-06-01");
booking.endDate = new Date("2024-06-10");
// A bug, or a rushed update
booking.endDate = new Date("2024-05-28"); // end is now before start
// No one catches this until a customer complainsValue object:
class DateRange {
constructor(
readonly start: Date,
readonly end: Date
) {
if (end <= start) throw new Error("End date must be after start");
}
}
// This throws immediately — the invalid state never exists
const range = new DateRange(new Date("2024-06-01"), new Date("2024-05-28"));The rule "end must be after start" lives inside the object itself. It's enforced every single time, not just when someone remembers to add the check.
Price with Currency
This one matters a lot in e-commerce. A price has two parts: an amount and a currency. Together, they mean something. Separately, they're dangerous.
// Mutable — a real bug waiting to happen
product.price = 100; // was 110 USD
product.currency = "EUR"; // updated
// But what if the currency update runs and the amount update fails?
// You now have 110 EUR — a 22% price difference, silently
// Value object
class Price {
constructor(
readonly amount: number,
readonly currency: string
) {
if (amount < 0) throw new Error("Price cannot be negative");
if (!["USD", "EUR", "GBP"].includes(currency)) throw new Error("Unsupported currency");
}
}
// You replace the whole price at once
product.price = new Price(100, "EUR"); // both values, one operation, one validation---
Email — and the deeper distinction
Email introduces something the previous examples don't: the difference between identity and value.
Consider two users named Alice. They're different people — different IDs, different accounts, different histories. Same name, different identity. Alice is an entity.
Now consider two email addresses: hello@example.com and hello@example.com. Are they different? No. They're the same email — full stop. It doesn't matter which object holds them, or when they were created. Same value, same thing. That's what makes Email a value object.
// Entities are compared by identity
const user1 = new User("Alice"); // id: 1
const user2 = new User("Alice"); // id: 2
user1.equals(user2) // false — different people, same name
// Value objects are compared by value
const email1 = new Email("hello@example.com");
const email2 = new Email("hello@example.com");
email1.equals(email2) // true — same email, periodThis is the real distinction between entities and value objects — not just immutability, but what makes two things the same. If the answer is "same ID", it's an entity. If the answer is "same data", it's a value object.
And because a value object is defined entirely by its data, it must arrive complete and valid — or not at all:
class Email {
readonly value: string;
constructor(raw: string) {
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw);
if (!valid) throw new Error(`"${raw}" is not a valid email`);
this.value = raw.toLowerCase().trim();
}
equals(other: Email): boolean {
return this.value === other.value;
}
}If you have an Email object, it's valid. If two Email objects have the same value, they're equal. No extra checks needed anywhere.
The Conclusions
First: Immutability isn't a restriction on you, the developer. It's a guarantee that the program doesn't end up in a state that doesn't make sense.
Second: If something in your domain can't be partially modified without breaking a rule or losing meaning — it belongs in /domain/value-objects.
Third: Value objects are pure business logic. No database, no HTTP, no framework. That's also why they're easy to port between languages — change the type syntax, keep the logic.
A Useful Test
When you're deciding whether something should be a value object, ask: "Does modifying one property of this thing, while leaving others unchanged, produce a valid result?"
- Change just the latitude of a GPS coordinate → most likely invalid
- Change just the currency of a price → invalid
- Change just the end date of a booking → potentially invalid, is the same room available?
- Change just the
titleof aBook→ fine if you're a publisher, bad if you're a bookstore
If the answer is "no, partial modification doesn't make sense" — or if two things with the same data should be considered equal — it's a value object.