Identity vs. Description: The Root of Everything
We can think about general stuff in two fundamentally different ways.
Some things have an identity that persists through time regardless of how they change. You are the same person you were ten years ago, even though your cells, your opinions, and probably your hairstyle are all different. A bank account is the same account even after thousands of transactions have changed its balance.
What makes these things "the same" isn't their current attributes — it's a thread of identity that runs through them across time.
On the contrary, other things are defined entirely by what they are right now. If you hand me a €50 note and I hand you back a different €50 note, nothing meaningful has changed. You don't care which specific note you have — you care about its denomination and currency. Two identical GPS coordinates describing the same point on Earth are interchangeable in every meaningful sense. There's no "which coordinate", there's only "what coordinate."
Eric Evans, who formalized Domain-Driven Design in his 2003 book, called these two categories Entities and Value Objects. Entities have identity. Value Objects have only attributes.
This distinction isn't just a programming convenience. It reflects something true about the domain you're modeling. Before you write a single line of code, you have to ask:
The answer shapes everything about how you implement it.
The Three Pillars
From that philosophical split, three concrete characteristics follow almost inevitably.
1. Defined by Attributes
A Value Object has no ID. No primary key, no UUID, no auto-increment column. Two Value Objects are the same if and only if all of their attributes are equal.
const a = new Money(100, "EUR");
const b = new Money(100, "EUR");
a.equals(b); // true — same amount, same currency, therefore the same valueCompare this to an Entity:
const userA = new User({ id: "usr_001", email: "alex@example.com" });
const userB = new User({ id: "usr_002", email: "alex@example.com" });
userA.equals(userB); // false — different IDs, different identities, even though the email matchesSame email address. Different people. Entities are compared by identity; Value Objects are compared by value.
2. Immutability
A Value Object, once created, never changes. Its properties are set in the constructor and cannot be modified later.
This is the part that confuses people coming from a mutable-by-default world.
Why can't I just update the amount?
The answer is that "changing" a Value Object isn't really a meaningful operation — if you modeled your domain the right way, then it might not make sense.
€100 doesn't simply become €150, even if this sounds logical and correct. Instead, you replace €100 with €150, because they are 2 different prices.
The old value didn't transform; you're simply working with a different value now, defined entirely by what it represents.
// Conceptually wrong, even if the language allows it:
price.amount = 150;
// Correct:
price = new Money(150, "EUR");
// Or, through a method that makes the intent explicit:
price = price.withAmount(150);The withAmount method creates and returns a new Money object internally. The original is untouched. If anything else held a reference to the old price, they still see €100 — which is almost always exactly what you want.
This immutable approach carries a direct consequence for how methods on a Value Object should behave — which brings us to the third pillar.
3. Side-Effect-Free Functions
This is the characteristic that gets the least attention, and it's arguably the most powerful.
Every method on a Value Object that produces a result should return a new Value Object rather than modifying the current one. No internal state changes. No hidden effects. Call the same method with the same inputs a thousand times and you'll get the same result every time.
class Money {
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Cannot add ${this.currency} and ${other.currency}`);
}
return new Money(this.amount + other.amount, this.currency);
}
applyDiscount(percentage: number): Money {
if (percentage < 0 || percentage > 100) {
throw new Error("Discount must be between 0 and 100");
}
return new Money(this.amount * (1 - percentage / 100), this.currency);
}
}
const price = new Money(200, "EUR");
const discounted = price.applyDiscount(20);
console.log(price.toString()); // EUR 200.00 — unchanged
console.log(discounted.toString()); // EUR 160.00This makes Value Objects trivially safe to pass around, share, cache, and test.
A function that takes a Money and returns a Money cannot corrupt your state. There are no surprises. This is the functional programming instinct applied directly to domain modeling — and it's why Value Objects compose so cleanly.
Quick Summary
A Value Object in Domain-Driven Design is an object that:
- has no identity
- is defined entirely by its attributes
- is immutable
- is compared by value rather than reference
Common examples include:
MoneyEmailDateRangeCoordinates
The Constructor as Gatekeeper
One of the most important responsibilities of a Value Object is self-validation. The constructor is where you enforce that an invalid Value Object can never exist — not just shouldn't exist, but structurally cannot exist.
Think about what this means in practice. If your Email class validates format in its constructor, then any Email object that exists anywhere in your system is guaranteed to be a valid email address. You don't need to check it at the service layer. You don't need to add a validation step before saving. You don't need to trust that whoever created the object remembered to validate it. If you have an Email, it's valid. That's the contract the type itself provides.
class Email {
private readonly value: string;
constructor(raw: string) {
const normalized = raw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
throw new Error(`"${raw}" is not a valid email address`);
}
this.value = normalized;
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}Notice two things: the constructor normalizes the input (trimming whitespace, lowercasing), and it throws immediately if the format is wrong. There's no isValid() method you're supposed to remember to call first. There's no email.validate() that returns a boolean you might forget to check. The object is either valid or it doesn't exist.
This pattern — making invalid states unrepresentable — is one of the most reliable ways to reduce bugs in a codebase. You're not relying on discipline or convention. You're relying on the type system.
Equality in Depth
JavaScript and TypeScript compare objects by reference by default. Two separate objects, even with identical properties, are not === to each other. This is why you implement an equals method explicitly on every Value Object.
const a = new Money(100, "EUR");
const b = new Money(100, "EUR");
a === b; // false — different object references in memory
a.equals(b); // true — same attributes, therefore the same valueFor a Value Object, equals should compare every attribute that contributes to the value's meaning. For Money, that's both amount and currency — because €100 and $100 are emphatically not the same thing.
class Money {
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
new Money(100, "EUR").equals(new Money(100, "USD")); // falseIn languages with stronger structural typing — Kotlin's data class, Python's @dataclass, Swift's structs — equality is often generated for you automatically based on all properties. In TypeScript, you implement it manually. Either way, the semantics are the same: equality is about value, not about which specific object instance you're holding.
Replace, Don't Mutate — The Semantic Difference
This deserves its own section because it's the thing that trips people up the most, even after they understand immutability intellectually.
When business logic requires a value to change, the instinct is to find the object and modify it. With Value Objects, the correct model is replacement, not modification. Or, if you're refueling a plane, this is the way to go:
// mutating the value object - might be fatal over the Pacific
refueling.quantity.amount_in_kg = refueling.quantity.amount_in_liters * 1.77; Here's the better one:
refueling.quantity = refueling.quantity.pumped(10000); // replacing with a new valueBetter — but 10000 is still a raw number. We haven't fixed the unit ambiguity: someone can still call pumped(10000) thinking in pounds while the system expects kilograms. Replacement solves the mutation problem; it doesn't solve the modeling problem.
Here's the right one:
refueling.pumped(Quantity(10000, 'liters')); // using an aggregateOr, consider a wider used situation: a shopping cart where the user applies a discount code. An item price needs to change. Here's the wrong mental model:
cartItem.price.amount = cartItem.price.amount * 0.9; // mutating the value objectHere's the right one:
cartItem.price = cartItem.price.applyDiscount(10); // replacing with a new valueThe difference isn't just stylistic. The second version makes clear in the code that you are working with a new quantity or price. The original quantity or price still exists, unchanged, and anything else that held a reference to it is unaffected. Even in systems far less dramatic than that, where multiple parts of the system might reference the same value, this safety becomes very real.
The with- pattern is a common convention for expressing this:
class DateRange {
constructor(
public readonly start: Date,
public readonly end: Date
) {
if (end <= start) throw new Error("End date must be after start date");
}
withStart(newStart: Date): DateRange {
return new DateRange(newStart, this.end);
}
withEnd(newEnd: Date): DateRange {
return new DateRange(this.start, newEnd);
}
contains(date: Date): boolean {
return date >= this.start && date <= this.end;
}
overlaps(other: DateRange): boolean {
return this.start < other.end && this.end > other.start;
}
}Each with- method produces a new DateRange with one attribute changed and the rest preserved. The original is never touched. And notice that DateRange itself carries useful behavior — contains and overlaps are domain logic that belongs exactly here, not scattered in utility functions somewhere.
Composability: Value Objects Inside Value Objects
One of the more elegant properties of Value Objects is that they compose naturally. A Value Object can contain other Value Objects, and the result is still a Value Object, not an Entity:
class Currency {
constructor(public readonly code: string) {
if (!/^[A-Z]{3}$/.test(code)) {
throw new Error(`"${code}" is not a valid ISO 4217 currency code`);
}
}
equals(other: Currency): boolean {
return this.code === other.code;
}
}
class Money {
constructor(
public readonly amount: number,
public readonly currency: Currency
) {
if (amount < 0) throw new Error("Amount cannot be negative");
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new Error("Cannot add amounts in different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency.equals(other.currency);
}
}Now Money contains a Currency, which itself validates that the currency code is a proper three-letter ISO code. Validation is layered. Invariants are distributed to the right level. Equality on Money delegates to equality on Currency, which is exactly correct.
If a car's cost is Money(100000, Currency('EUR')) and a house's cost is Money(100000, Currency('EUR')), then their price is exactly the same.
A postal address might similarly compose a Country Value Object, a PostalCode Value Object (whose validation logic legitimately differs by country), and a StreetLine. Each piece is responsible for its own rules. The outer Value Object is responsible for the rules that span all of them.
Solving Primitive Obsession
There's a well-known code smell called primitive obsession — using raw primitive types like string, number, and boolean to represent domain concepts that deserve their own type. The appointment example at the top of this article is primitive obsession at its most dangerous. Here's what it looks like when you cure it:
// Before: four strings, any order is equally valid to the compiler
function scheduleAppointment(
doctorId: string,
patientId: string,
notes: string,
date: string
) { ... }
// After: the types make the wrong order impossible
function scheduleAppointment(
doctor: DoctorId,
patient: PatientId,
notes: AppointmentNotes,
date: AppointmentDate
) { ... }DoctorId and PatientId might both wrap a string internally, but they're different types. You cannot accidentally pass a PatientId where a DoctorId is expected.
class DoctorId {
constructor(public readonly value: string) {
if (!value.startsWith("doc_")) throw new Error("Invalid doctor ID format");
}
equals(other: DoctorId): boolean { return this.value === other.value; }
}
class PatientId {
constructor(public readonly value: string) {
if (!value.startsWith("pat_")) throw new Error("Invalid patient ID format");
}
equals(other: PatientId): boolean { return this.value === other.value; }
}
// This is now a compile-time error:
scheduleAppointment(patientId, doctorId, date, notes);
// ❌ Argument of type 'PatientId' is not assignable to parameter of type 'DoctorId'You've moved an entire class of bugs from runtime to compile time. That's not a small improvement — that's a bug that never reaches production at all, ever, regardless of who writes the calling code or how tired they are.
The stakes of primitive obsession scale with the domain. In appointment scheduling, the consequence is corrupted data. In other domains, they can be considerably more severe.
On 23 July 1983, Air Canada 143 flight found itself out of fuel mid-flight, at 41,000 ft, at half-distance between Montreal and Edmonton. This happened when Canada was in its process of transition from imperial to metric, and the ground maintenance team mistakenly used 1.77 lbs/liter instead of 0.8 kg/liter. Because airport staff were calculating liters pumped but the pilots were using kilograms to measure the fuel, the plane took off with 1.77 * liters_pumped kilograms instead of 0.8 * liters_pumped kilograms. The result: the plane only had 10,115 kg of fuel, while around 23,500 kg were necessary.
The good news? Captain Robert Pearson was a trained glider pilot. In an unprecedented maneuver, he managed to glide a Boeing 767 — an enormous machine never designed for anything of the sort — to a safe landing. Everyone survived. This event is famously known as the "Gimli Glider".
The mistake wasn't arithmetic — it was treating a quantity as a raw number. A FuelQuantity that cannot exist without its unit would have made this particular class of error impossible before anyone approached the plane. Captain Pearson's gliding skills were extraordinary. Your domain model shouldn't require them.
Context Matters: When a Value Object Becomes an Entity
Here's something worth saying clearly, because it's the nuance that separates someone who understands DDD from someone who's memorized its patterns: whether something is a Value Object or an Entity is not a universal truth. It depends on the bounded context you're working in.
An email address is almost always a Value Object. Two users with the same email address have the same email — there's no meaningful distinction between "which email address object" they have.
But in an email marketing platform, that same email address might have:
- a verification date
- a send history
- an unsubscribe flag
- a bounce count
Suddenly the email address has a lifecycle. It changes over time. It matters which specific record you're looking at, not just what its current value is. In that context, an email address is an Entity.
The question to ask is always:
in this context, does the history and identity of this thing matter, or only its current value?
The same concept can be a Value Object in one bounded context and an Entity in another. DDD doesn't give you universal answers — it gives you a vocabulary precise enough to ask the right question for your specific domain.
Value Objects Inside Aggregates
If you've read our article on Aggregates, you'll already understand that Aggregates are commonly referenced as consistency boundaries — clusters(bunches) of objects that change together under the control of a single root. Value Objects are almost always found inside Aggregates, and they complement them precisely because they operate at different, lower, more focused levels of responsibility.
Pro tip: The Aggregate Root manages which changes are allowed and in what sequence. The Value Objects it contains manage what constitutes a valid value for each individual concept.
class Order { // Aggregate Root
private items: OrderItem[] = [];
private shippingAddress: PostalAddress; // Value Object
private status: OrderStatus; // Value Object
addItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
if (this.status !== OrderStatus.DRAFT) {
throw new Error("Cannot add items to a confirmed order");
}
// Quantity and Money are Value Objects — they already validated themselves
this.items.push(new OrderItem(productId, quantity, unitPrice));
}
total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
new Money(0, this.shippingAddress.currency)
);
}
}The Order enforces rules about when items can be added. The Money and Quantity Value Objects enforce rules about what a valid price and quantity look like. Neither layer bleeds into the other. They work together cleanly.
A Few Things to Watch Out For
Don't put Value Object behavior in utility classes. If you have a PriceUtils.applyDiscount(amount, percentage) function floating somewhere, that logic almost certainly belongs on a Money Value Object. Behavior that belongs to a concept should live with that concept.
Don't make your Value Objects anemic. An anemic Value Object holds data but does nothing with it. If your Money class has no add, no applyDiscount, no equals — it's just a struct. Value Objects should carry the domain logic that belongs to them.
Don't share mutable references to Value Objects across Aggregates. Since Value Objects are immutable, sharing them is actually safe — two Aggregates can reference the same Money instance without risk. But if you ever find yourself wanting a mutable shared reference, stop and reconsider the design.
Don't give Value Objects their own database table unless you have a specific reason. Value Objects typically have no independent lifecycle, so they're embedded in the same table as their owning Entity. An Order row in the database includes the ShippingAddress columns directly — there's no separate addresses table with a foreign key. In other words, the email remains a string and should be stored in the database in a column of type varchar(128) no matter how many validations it underwent. The GPS coordinates are, at the end of the day, just 2 double values. Even complex systems don't need a separate table for registering different prices. And if they somewhat did, than it would be a textbook example of when you need an Entity, not a Value Object.
Remember: In code, the Value Object's purpose is to assert the integrity of existent data: if it exists, then it's valid.
What It All Adds Up To
The reason Value Objects matter isn't simply that they're a clever pattern, or even that they reduce some file sizes, decreasing complexity and improving the tests.
When your codebase has a Money type instead of a raw number, the type itself communicates that amounts have currencies, that currencies must be valid ISO codes, that you can't add different currencies without explicit handling, and that amounts should be treated as atomic values. All of that domain knowledge — which otherwise lives in developers' heads, in comments, in validation scattered across service layers, in informal agreements nobody wrote down — is right there in the type.
This connects back to what Domain-Driven Design is about at the architecture level: bringing the domain into the code in a way that reflects the real rules of the business. Value Objects are where that promise becomes concrete and technical.
The philosophy and the implementation aren't two separate things. The immutability, the equality by value, the self-validation — they all follow directly from the observation that some concepts in your domain have no meaningful identity. Once you see that clearly, the rest writes itself.