Intermediate14 min readOO Analysis & Modelinglive prototype

Domain Modeling

A domain model is a web of objects that mirror the real-world domain in the *exact language the domain experts use* — and the first skill is telling Entities, Value Objects, and Services apart.

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.

typescript
// 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 interchangeable

Beware 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.

try 01

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.

try 02

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.

try 03

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

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