Clean Architecture: Entity vs Value Object
Two of the smallest building blocks in Domain-Driven Design — and the ones engineers most often get wrong. A deep, practical guide to identity, equality, immutability, and how to decide which one to reach for.
Every time I review a Clean Architecture codebase that “isn’t clicking”, the bug is in the same place: someone modeled a domain concept as the wrong primitive. A User becomes a Value Object and suddenly two distinct people compare as equal. A Money becomes an Entity and the team starts wondering why Money.create(100, 'EUR').equals(Money.create(100, 'EUR')) returns false.
These two building blocks — Entities and Value Objects — are the foundation everything else in DDD is built on. Get them right and the rest of the system organizes itself. Get them wrong and you’ll be papering over the mistake with if statements for years.
This article is the long version of the conversation I have with every engineer joining a domain-driven codebase.
The one-line definition
Before any code, internalize this:
An Entity is defined by its identity. A Value Object is defined by its attributes.
That’s it. Everything else — immutability, equality, factories, validation — is a consequence of that single distinction.
Two people with the same name and date of birth are still two different people. They are Entities: identity is what matters, attributes can change. Two banknotes of 100€ are interchangeable; if I swap one for the other you don’t care. They are Value Objects: attributes are what matter, identity is irrelevant.
Identity vs equality
Programming languages give you == and === and call it a day. Domain modeling needs more nuance, because “equal” depends on the question you’re asking.
- “Are these two records the same person?” → identity comparison.
- “Do these two amounts represent the same money?” → value comparison.
In this codebase the two base classes encode that difference directly:
export abstract class Entity<T> { id: string;
constructor(id: string) { this.id = id; }
public equals(entity?: Entity<T>): boolean { if (entity === null || entity === undefined || !(entity instanceof Entity)) { return false; } return this.id === entity.id; }}export abstract class ValueObject<T extends ValueObjectProps> { constructor(protected props: T) { this.props = { ...props }; }
public equals(vo?: ValueObject<T>): boolean { if (vo === null || vo === undefined || vo.props === undefined) return false; return JSON.stringify(this.props) === JSON.stringify(vo.props); }}Read those two implementations side by side. The Entity compares only the id. The Value Object compares all the props. Not because of a stylistic preference — because that’s literally what each concept means.
If you find yourself writing entity.name === otherEntity.name to check if two users are “the same”, stop. You’re collapsing identity into attributes. That’s the bug.
Why Value Objects must be immutable
A Value Object’s identity is its set of attribute values. So if you mutate one of those values, you’ve created a different Value Object — but kept the same memory reference. Now you have a phantom: code holding the “old” value sees the “new” one without warning.
A few examples of the chaos this causes:
- A
Moneyinstance held inside an order’stotal. Someone mutates the currency. The order silently changes price. - An
EmailAddresscached in aSet. Someone normalizes its casing in place. The set’s hash lookups break. - A
DateRangereferenced by two reservations. Someone extendsendDate. Both reservations now overlap with another booking.
The fix is the same in every case: Value Objects never expose setters. The only way to “change” one is to create a new one. JavaScript can’t enforce this for you, so the discipline has to come from the design.
export class TechTag extends ValueObject<TechTagProps> { private constructor(props: TechTagProps) { super(props); }
public static create(label: string): TechTag { if (label.trim().length === 0) throw new Error('TechTag.label cannot be empty'); return new TechTag({ label: label.trim() }); }
get label(): string { return this.props.label; }}Notice three things:
- The constructor is private. Outside code can never bypass validation.
- There’s no setter. The only way to get a new value is
TechTag.create(...)again. - The getter returns a primitive, not the internal
props. Callers can’t reach in and mutate.
That’s not boilerplate. Each line is doing real work to keep the invariant safe.
Why Entities are mutable (within reason)
Entities exist precisely because their attributes change over time. A user updates their email; the user is still the same user. An order moves from pending to paid to shipped; it’s still the same order with the same id.
The way Clean Architecture handles this isn’t “let any caller mutate any field”. It’s: expose methods that represent meaningful domain transitions, and validate inside them.
export class Order extends Entity<OrderInternalProps> { // ...
public markAsPaid(paidAt: Date): void { if (this.status !== 'pending') { throw new Error(`Cannot mark a ${this.status} order as paid`); } this.props.status = 'paid'; this.props.paidAt = paidAt; }}The Entity decides what “marking as paid” means. Outside code calls order.markAsPaid(now) instead of order.status = 'paid'. That’s the whole point: the Entity is the guardian of its own consistency.
If you find an Entity that’s just a public-fields data bag, you don’t have an Entity. You have a DTO with extra steps.
How to decide: a checklist
When you sit down to model a new concept, walk through these questions in order:
- Two instances with identical attributes — are they the same thing? Yes → Value Object. No → Entity.
- Does this concept have a lifecycle? (created, updated, deleted, transitioned) Yes → Entity. No → leans Value Object.
- Will something else need a stable reference to it? (foreign key, audit log, URL) Yes → Entity. No → leans Value Object.
- Can I replace it wholesale instead of mutating it? Yes → Value Object. No (because consumers hold a reference) → Entity.
Worked examples:
User→ two users with the same name are not the same person → Entity.EmailAddress→ two emails with the same string are interchangeable → Value Object.Order→ has a lifecycle, audited, referenced by id → Entity.Money(amount, currency)→ 100€ is 100€, no lifecycle → Value Object.Address→ ambiguous. If you only need to print it on an invoice → Value Object. If users edit “their primary address” and other systems reference it → Entity.
That last one is important: the same concept can be an Entity in one bounded context and a Value Object in another. Don’t ask “what is X in general?”. Ask “what is X in this context?”.
The trap of “anemic” Value Objects
A common mistake is thinking a Value Object is just “a wrapper around a string with a validation”. That’s the start, not the end. A real Value Object is also the home of every operation that belongs to the concept.
export class Money extends ValueObject<MoneyProps> { // factory + getters omitted
public add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add Money in different currencies'); } return Money.create(this.amount + other.amount, this.currency); }
public isPositive(): boolean { return this.amount > 0; }}Look what add does: it returns a new Money, doesn’t mutate, and centralizes the cross-currency rule. Without this method, that if would be duplicated in every use case that adds amounts. With it, the rule has one home.
A Value Object that only validates input is a missed opportunity. Ask yourself: what verbs belong on this concept? Put them on the class.
The trap of god Entities
The opposite mistake is to put everything on the Entity. The user has an email, so the email validation lives on User.create. The user has an address, so address normalization lives on User.create. Six months later User is a 600-line file and any change to email rules forces you to retest user creation.
The fix is to compose Entities out of Value Objects, not strings:
interface UserInternalProps { id: string; email: EmailAddress; fullName: FullName; passwordHash: PasswordHash;}
export class User extends Entity<UserInternalProps> { public readonly email: EmailAddress; public readonly fullName: FullName;
public static create(data: UserData): User { const email = EmailAddress.create(data.email); const fullName = FullName.create(data.firstName, data.lastName); const passwordHash = PasswordHash.fromPlainText(data.password); return new User({ id: data.id, email, fullName, passwordHash }); }}User.create is now a composition of small, well-tested Value Objects. The user doesn’t know how email validation works — EmailAddress does. That’s separation of concerns at the domain level, and it’s the entire reason this architecture pays off.
Common smells, and what they actually mean
A short field guide:
- “My equals method compares by name and date of birth” → you’re modeling identity through attributes. It’s an Entity. Use the id.
- “I keep needing to clone this object before passing it around” → you have a mutable Value Object. Make it immutable.
- “Two of the same thing exist in the database with the same fields” → fine for Value Objects, broken for Entities. Check your invariants.
- “My VO has a setter for one field” → you have an Entity in disguise, or you need to return a new VO from a method. Pick one.
- “My Entity has no methods, only getters and setters” → it’s not an Entity, it’s a DTO. Move logic into it or accept that you don’t have a domain model.
Why this matters more than people think
Engineers sometimes shrug at this distinction as “academic”. It isn’t. The Entity / Value Object split is what allows the rest of Clean Architecture to work:
- Repositories can only return Entities — because Entities are the things with identity, the things you query by id.
- Use cases orchestrate Entities and pass Value Objects between them — because Value Objects are safe to share without surprises.
- Equality in tests becomes meaningful.
expect(actual.equals(expected))does the right thing whether the type isMoneyorOrder. - Refactors stay local. Changing how
EmailAddressvalidates doesn’t touch any Entity or use case.
The minute you blur the line, you start leaking domain concerns into infrastructure, infrastructure concerns into use cases, and the architecture stops paying its rent.
The smallest decisions in a domain model are the ones with the longest reach. Get Entity vs Value Object right and the rest of the architecture starts holding itself up. Get it wrong and no amount of layering will save you.