The idea
What it is
A domain model is a set of objects that mirror the real-world domain your software is about — orders, customers, payments, money — and the relationships between them. It's not your database schema and it's not your UI; it's a conceptual model made of code, where each class represents a meaningful concept and carries both the data and the behavior that belong to it. Done well, a domain model lets you read the code and recognize the business in it.
The thing that makes a model good is that it speaks the ubiquitous language — the same words the domain experts use. Picture a local's hand-drawn map: it labels places with the names locals actually say, not GPS coordinates. A stranger and a local can point at the same spot and mean the same thing. A domain model is that map for your business: when the warehouse manager says "line item" and your code has an OrderLine, when accounting says "a refund is money owed back" and your Money value object knows how to subtract — analyst and engineer are finally pointing at the same thing.
The very first modeling skill — the one this lesson drills — is classifying each concept correctly. Is this thing an Entity (it has a distinct identity that survives change), a Value Object (it's defined purely by its attributes), or a Service (it's behavior that doesn't belong to any single object)? Get those three right and most of the model falls into place.
The one sentence to remember
A domain model is real-world concepts as objects, named in the ubiquitous language of the domain experts — and every concept is either an Entity (identity), a Value Object (just its values), or a Service (homeless behavior).
Mechanics
How it works
Entity — identity that survives change
An Entity is a thing you track as itself over time. It has a distinct identity — usually an ID — that stays the same even as every other attribute changes. Order #4471 is the same order whether it's pending, paid, or shipped; the customer renaming themselves doesn't make them a different Customer. The defining test: two Entities with identical fields are still two different things. Two brand-new orders that happen to have the same items and total are not the same order — they have different IDs. So Entity equality is by identity, not by value: a.equals(b) means a.id === b.id.
Entities also have a lifecycle — they're created, they change state, they may eventually be archived or deleted — and the model is usually responsible for protecting the rules of that lifecycle (you can't ship an unpaid order). Typical entities: Order, Customer, Product, Account.
Value Object — defined entirely by its attributes
A Value Object has no identity — it's defined completely by its attributes, and two value objects with equal attributes are interchangeable. Money is the classic example: two separate $5 notes are equal; you'd never ask "but which $5 is it?" The same goes for an Address, a DateRange, a Color, an EmailAddress. Because they have no identity, value objects should be immutable: once created they never change. Need a different amount? You don't mutate the Money — you create a new one. This is exactly the immutability you learned earlier, and the reason value objects must override equals/hashCode to compare by value, not by reference.
Value Objects sit on top of two earlier foundations
A Value Object is where the immutability and equals/hashCode lessons pay off. Compare by value (override equals/hashCode so equal attributes are equal), and make the object immutable so it can be shared freely and used safely as a map key. Mutable value objects are a bug factory — change one and you silently change everything that aliased it.
Service — behavior with no natural home
Sometimes an operation involves several objects and doesn't belong to any single one. Transferring money touches two accounts; computing tax depends on jurisdiction rules, not on the order alone. Forcing that logic onto one entity makes it awkward ("why does Account know how to debit another account?"). A Service is the home for such behavior: a stateless object named after a verb — TransferService, TaxCalculator, PaymentService, PricingService. Stateless is the key word: a service holds no domain data of its own; it operates on entities and value objects passed to it. If you find yourself adding fields to a service, it probably wanted to be an entity.
Aggregate — a consistency boundary with one root
Real domains have clusters of objects that only make sense together. An Order owns its OrderLines — a line item has no independent life outside its order. An Aggregate is such a cluster, treated as one unit for consistency, with a single aggregate root as the only entry point. Outside code talks to the Order; it never reaches in and grabs an OrderLine directly. The root enforces the invariants of the whole cluster — "order total equals the sum of its lines," "a shipped order can't add lines" — so the rules can never be broken from the outside. Add a line by calling order.addLine(...), and the Order (the root) keeps everything consistent.
A pure domain layer — no persistence or UI inside
The model stays valuable only if it stays pure: a domain object should be about the business, not about SQL, HTTP, or React. Keep database mapping, JSON serialization, and screen layout out of the model and in the layers around it (repositories, controllers, views). When the domain layer has no idea a database exists, you can test the business rules in milliseconds, swap the storage engine without touching the model, and read the code as a description of the domain rather than a tangle of framework calls.
// ENTITY: identity-based equality — same id ⇒ same Order
class Order {
constructor(public readonly id: string) {} // identity persists through change
equals(other: Order) { return other instanceof Order && other.id === this.id; }
}
const a = new Order("ord-1");
const b = new Order("ord-1");
a.equals(b); // true — same identity, even if state differs
// VALUE OBJECT: value-based equality, immutable — no identity at all
class Money {
constructor(readonly amount: number, readonly currency: string) {} // frozen
equals(o: Money) { return o.amount === this.amount && o.currency === this.currency; }
add(o: Money) { return new Money(this.amount + o.amount, this.currency); } // returns NEW
}
new Money(5, "USD").equals(new Money(5, "USD")); // true — two $5 are interchangeableBeware the anemic domain model
If every domain class is just fields with getters and setters, and all the logic lives in ...Service classes, you have an anemic domain model — an object-oriented facade over procedural code. It pays the full cost of a domain model and gets none of the benefit. The fix: put behavior on the model. order.addLine(...), order.total(), and money.add(...) belong on Order and Money. Reserve services for the genuinely homeless behavior (TransferService), not as a dumping ground for logic that should live on an entity.
Interactive prototype
See it. Build it. Break it.
A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.
About this simulation
Ten concepts from an e-commerce domain start in an unsorted tray. Drop each one into the bucket where it belongs — Entity (has identity & a lifecycle), Value Object (immutable, compared by value), or Service (stateless behavior). The console gives a one-line reason for every call, and a model-correctness meter climbs as you get them right. Once everything is sorted, hit reveal model to see how Order becomes an aggregate root holding OrderLines and Money.
Hands-on
Try these yourself
Open the prototype above, predict what happens, then verify.
Classify a Value Object and read the reason
From the unsorted tray, drop Money into the Value Object bucket. The console confirms with a one-line reason — Money → Value Object: two $5 notes are interchangeable, no identity — and the model-correctness meter ticks up. Try a wrong one on purpose: drop Order into Value Object and watch the ✗ feedback explain that an order has identity and a lifecycle, so it's an Entity.
Fill all three buckets and watch the meter
Sort the remaining chips — Customer, Product and OrderId reveal Entity vs Value Object subtleties; TaxCalculator and PaymentService are stateless behavior; Address, DateRange and EmailAddress are pure values. The per-bucket counts and the model-correctness meter update with every drop. Use Auto-classify if you want to snap every chip into its correct bucket and study the result.
Reveal the aggregate
Once everything is classified (or after Auto-classify), press reveal model to expand the aggregate view: Order sits at the top as the aggregate root, holding a list of OrderLines, each carrying a Money value object — the relationship diagram that shows why Order is the only entry point. Hit Reset to clear the buckets and start over.
In practice
When to use it — and what trips people up
When a domain model earns its keep
Reach for a rich domain model when the business logic is the hard part — interesting rules, invariants, state transitions, and money. That's where entities-with-behavior, immutable value objects, and aggregate roots that protect their invariants pay off, and where speaking the ubiquitous language keeps engineers and domain experts in sync. Start the design by listing the nouns of the domain and classifying each as Entity, Value Object, or Service — exactly the drill in the prototype.
When the app is mostly CRUD over forms with little real logic, a heavyweight model is overkill — a thin data layer is fine, and forcing DDD machinery onto it just adds ceremony. The judgment call is how much logic is there, and how often does it change? The more the rules churn, the more a clear model with behavior-on-the-objects repays the effort.
Don't let the model leak its surroundings
The fastest way to ruin a domain model is to let persistence and transport concerns seep in — ORM annotations everywhere, JSON shape dictating your classes, validation tied to HTTP. Keep the domain layer pure and push those concerns to the edges. If you can't unit-test a business rule without a database running, the model has already leaked.
What it gives you
- The code reads like the business — concepts and the ubiquitous language map straight onto classes, so experts and engineers point at the same things.
- Rules live where they belong — behavior on entities and value objects, invariants protected by aggregate roots, instead of scattered across services.
- Immutable value objects are safe to share, cache, and use as map keys, and they remove a whole class of aliasing bugs.
- A pure domain layer is fast and trivial to unit-test, and survives swapping the database, framework, or UI.
Common mistakes
- It's overkill for simple CRUD apps — modeling machinery adds ceremony when there's barely any logic to protect.
- Classifying concepts (Entity vs Value Object vs where an aggregate boundary goes) takes real judgment and is easy to get wrong early.
- Drift toward an anemic model is constant pressure — logic keeps sneaking into services unless the team is disciplined.
- Keeping the model pure requires extra layers (repositories, mappers) to hold persistence and transport at arm's length.
Reference
Code & further reading
A minimal reference implementation and pointers worth bookmarking.
// VALUE OBJECT — immutable, equal by value, no identity
class Money {
constructor(
readonly amount: number, // in minor units (cents)
readonly currency: string,
) {
Object.freeze(this); // immutable
}
add(other: Money): Money {
if (other.currency !== this.currency) throw new Error("currency mismatch");
return new Money(this.amount + other.amount, this.currency); // returns a NEW Money
}
equals(o: Money): boolean {
return o instanceof Money && o.amount === this.amount && o.currency === this.currency;
}
}
// VALUE OBJECT inside an entity — a line has no identity of its own
class OrderLine {
constructor(readonly product: string, readonly qty: number, readonly unitPrice: Money) {}
lineTotal(): Money {
let sum = new Money(0, this.unitPrice.currency);
for (let i = 0; i < this.qty; i++) sum = sum.add(this.unitPrice);
return sum;
}
}
// ENTITY / AGGREGATE ROOT — identity persists; the only entry point to its lines
class Order {
private lines: OrderLine[] = [];
constructor(readonly id: string, readonly currency = "USD") {}
addLine(product: string, qty: number, unitPrice: Money): void {
this.lines.push(new OrderLine(product, qty, unitPrice)); // root guards the invariant
}
total(): Money {
return this.lines.reduce((sum, l) => sum.add(l.lineTotal()), new Money(0, this.currency));
}
equals(other: Order): boolean {
return other instanceof Order && other.id === this.id; // identity, not value
}
}
const order = new Order("ord-4471");
order.addLine("Keyboard", 2, new Money(4999, "USD"));
order.addLine("Mouse", 1, new Money(2999, "USD"));
order.total(); // Money { amount: 12997, currency: "USD" }References & further reading
8 sources- Articlemartinfowler.com
Martin Fowler — Domain Model (PoEAA)
The canonical pattern definition: an object model of the domain incorporating both behavior and data — the one-page reference behind this whole lesson.
- Articlemartinfowler.com
Martin Fowler — AnemicDomainModel
Why data-only classes with all logic in services are an anti-pattern — directly motivates the 'put behavior on the model' warning.
- Articlemartinfowler.com
Martin Fowler — ValueObject
Value-based equality and immutability across several languages — the bridge from the equals/hashCode and immutability foundations into modeling.
- Docsdomainlanguage.com
Eric Evans — DDD Reference (free PDF)
Evans' own concise definitions and pattern summaries of Entity, Value Object, Service, and Aggregate — a free, printable cheat sheet for the building blocks.
- Articleen.wikipedia.org
Wikipedia — Domain model
A neutral overview: a conceptual model of the domain, independent of database and architecture, usually drawn as a UML class diagram.
- Articleen.wikipedia.org
Wikipedia — Value object
Equality not based on identity, immutability, and short implementation examples across C#, C++, Python, Java, Kotlin, and Ruby.
- Book
Eric Evans — Domain-Driven Design: Tackling Complexity in the Heart of Software
The book that introduced this vocabulary — ubiquitous language, entities, value objects, services, and aggregates — in depth.
- Book
Vaughn Vernon — Implementing Domain-Driven Design
The hands-on companion to Evans, with concrete guidance on drawing aggregate boundaries and keeping the model pure.
Knowledge check
Did it land?
Quick questions, answers revealed on submit. Sign in to save your best score.
question 01 / 05
What distinguishes an Entity from a Value Object?
question 02 / 05
Two newly created Order objects happen to have the same items and the same total. Are they equal?
question 03 / 05
Why should a Value Object such as Money be immutable?
question 04 / 05
Where does the behavior of transferring money between two accounts most naturally belong?
question 05 / 05
In an aggregate where Order is the root containing OrderLines, how should outside code add a line item?
0/5 answered