← pllu.net

1. Why DDD exists

Most software fails not because the code is wrong, but because the code models the wrong thing. A developer reads a ticket, builds what they think it says, and ships something the business doesn't recognise. Over a few years of this, the codebase drifts away from the work it's supposed to do, and every change becomes a translation exercise between two different vocabularies.

Eric Evans named this problem in 2003 in Domain-Driven Design: Tackling Complexity in the Heart of Software. The proposal was simple to state and unreasonably hard to do: stop treating the domain (the actual business) as something to be summarised on a napkin and then translated into "real" technical artefacts. Instead, make the domain model the heart of the system. Speak about it in plain language with the people who run the business. Write the code in that same language. When the model changes, both move together.

DDD isn't a framework or a library. It's a set of practices and a shared vocabulary for doing what good engineers were always trying to do anyway: keep the software honest about the world it represents.

Conventional layered app UI Service / controller logic "Models" (anaemic data bags) Database DDD-shaped app UI Application services (thin) Domain model entities, values, aggregates, rules Infrastructure (DB, queues, APIs)
DDD shifts behaviour out of the service layer and into the model itself. The "models" stop being data bags and start being where the business rules live.

2. Ubiquitous language

The first move in DDD is linguistic, not technical. The team (developers and the people who understand the business) agrees on a precise vocabulary for the domain. Every important concept gets one name, used everywhere: in conversation, on whiteboards, in tickets, in class names, in database tables, in API payloads.

This sounds banal until you've worked in a codebase where the same thing is called User, Account, Member, Subscriber and Customer in five different places. Each name carries slightly different assumptions, and every time data crosses one of those boundaries, somebody has to translate. The translations leak.

The ubiquitous language is the discipline of refusing to do that. If the salespeople call them "accounts", the code calls them accounts. If the model needs a distinction the language doesn't have, you invent the word together and then use it everywhere. A new developer reading the code should be able to walk into a meeting with the business and follow along.

A telltale sign: when developers and the business use different words for the same thing, or the same word for different things, you're paying a tax on every conversation. DDD treats that tax as the bug.

3. Bounded contexts

Here's the catch: a single ubiquitous language across a whole company is impossible. The word "order" means one thing to sales, another to fulfilment, another to accounting. Trying to force one canonical Order class to serve all three teams produces a monstrosity with sixty fields, half of which are null at any given time.

DDD's answer is the bounded context: an explicit boundary inside which one model and one language are consistent. Outside the boundary, the same word may mean something different, and that's fine. The boundary is real: separate codebase or module, separate database schema, separate deployment if needed, and crucially, separate vocabulary.

Sales context how a deal gets made Order prospect, discount, quote, owner, expected close date, commission split Fulfilment context how it gets to the customer Order pick list, packed by, carrier, tracking, warehouse, weight, scheduled dispatch Accounting context how the money is booked Order invoice number, VAT, ledger entries, revenue recognition, payment status
Three bounded contexts, three different "Orders". Each one is the right model for its job. Forcing them into a single class is what produces unmaintainable systems.

Bounded contexts are the strategic core of DDD. They line up naturally with team boundaries, with microservice splits (when those make sense), and with the actual seams in the business. The hard work isn't drawing the boxes; it's deciding where the edges go, and then defending them.

4. Context maps

Contexts have to talk to each other. The context map documents how. It's a high-level picture of which contexts exist, who owns each one, and what kind of relationship sits on every line between them. The relationship type matters because it tells you what to expect when the other side changes.

RelationshipWhat it means
Shared kernelTwo teams share a small piece of model and code. Changes need joint agreement. Cheap when it works, painful otherwise.
Customer / supplierUpstream supplies, downstream consumes. The supplier accepts feature requests; the consumer can plan around a known cadence.
ConformistDownstream conforms to upstream's model with no negotiation. You don't get a choice. Common with third-party APIs.
Anti-corruption layer (ACL)Downstream builds a translation layer to keep the upstream's model from polluting its own. Worth the cost when the upstream's concepts are wrong for you.
Open host serviceUpstream publishes a stable, well-documented protocol. Anyone can consume it without bilateral coordination.
Published languageA formal shared vocabulary (a schema, a spec) used between contexts. Often paired with an open host service.
Separate waysThe contexts don't integrate at all. Sometimes the right answer.
Sales team A Fulfilment team B Accounting team C Legacy ERP vendor, frozen Anti-corruption layer customer / supplier published language conformist upstream
A context map. Sales feeds Fulfilment as a customer/supplier; Fulfilment publishes a stable language to Accounting; the legacy ERP is conformist, so an anti-corruption layer translates its model into Fulfilment's terms.

5. The tactical toolbox

Inside a single bounded context, DDD offers a kit of building blocks for the model itself. They're not novel in isolation, but they have specific names and specific jobs, which is the point: the language is part of the design.

BlockJobMarker
EntityA thing with identity that persists over time. Two entities with the same attributes but different ids are different things.has an id; mutable
Value objectA thing defined entirely by its attributes. Money(10, "GBP") is the same as any other Money(10, "GBP").no id; immutable; equality by value
AggregateA cluster of entities and values treated as one unit for the purpose of changes. Has a single root entity that's the public face.consistency boundary
RepositoryAn abstraction that loads and saves aggregates. The domain talks to it; the implementation knows about databases.one per aggregate root
Domain serviceBehaviour that doesn't fit naturally on a single entity or value, but is still pure domain logic.stateless; named for the operation
FactoryEncapsulates the creation of complex aggregates so the rules of valid construction live with the model.often static method on the root
Domain eventA fact about something that happened in the domain. Other parts of the system react to it.past tense; immutable

Entities vs value objects, concretely

A Customer with id c-42 is an entity. If she changes her name and address, she's still c-42. Her identity is the point.

An Address with street, city and postcode is a value object. If you change the postcode, what you now have is a different address, not a mutated version of the old one. You replace, you don't update. Treating things that should be values as if they were entities (giving an address its own row, primary key and lifecycle) is one of the most common modelling mistakes DDD pushes back on.

// Value object: equality by value, immutable
class Money {
  constructor(readonly amount: number, readonly currency: string) {
    if (amount < 0) throw new Error("amount must be >= 0");
  }
  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error("mismatch");
    return new Money(this.amount + other.amount, this.currency);
  }
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

// Entity: equality by id, behaviour lives on the class
class Customer {
  constructor(readonly id: CustomerId, private name: string) {}
  rename(newName: string) {
    if (!newName.trim()) throw new Error("name required");
    this.name = newName;
  }
  equals(other: Customer): boolean {
    return this.id.equals(other.id);
  }
}

6. Aggregates in detail

Aggregates are the idea most worth taking time over, because they're where DDD's strategic and tactical sides meet. An aggregate is a cluster of objects you can only change as a unit. The aggregate root is the one entity inside it that everything else talks to; nothing outside the aggregate gets to hold references to the inner pieces.

Order aggregate Order aggregate root OrderLine entity (inner) Money value Address value Customer different aggregate refers to root by id ✓ External code outside reaching into inner entities ✗
Outside code may hold a reference to the aggregate root, by id. Reaching into inner entities (or holding direct references to them) is forbidden — that's how invariants survive.

Why this rule? Because the aggregate is the unit at which the model promises consistency. The Order root enforces "total equals the sum of lines"; if external code can grab an OrderLine and change its price without going through the root, that invariant is dead. The boundary buys you a property worth a lot in practice: when you load an aggregate, change it, and save it, the rules hold; you don't have to defend them in a hundred call sites.

Good aggregates are small. Beginners tend to draw the boundary too wide, hauling whole graphs into memory and serialising them through a single root. The guideline that has stood up well: one aggregate, one transaction, one consistency rule. Cross-aggregate consistency is achieved by domain events and eventual consistency, not by stuffing everything into one root.

7. Layers around the domain

The model sits in the middle. Around it, DDD codebases usually arrange a few thin layers, each with a strict job:

UI / interface adapters HTTP handlers, CLI, GraphQL resolvers Application layer use cases, transactions, orchestration Domain layer entities, values, aggregates, services Pure domain model no I/O, no frameworks
Dependencies point inward. The domain has no idea HTTP or Postgres exist. The application layer orchestrates use cases without owning business rules.
LayerWhat lives hereWhat does NOT
InterfaceControllers, CLI, transports, JSON serialisationBusiness rules
ApplicationUse case orchestration, transactions, security checks, calling domain services and repositoriesBusiness rules; domain logic
DomainEntities, value objects, aggregates, domain services, domain eventsSQL, HTTP, framework annotations, anything I/O
InfrastructureRepository implementations, message bus, external API clients, ORM mappingsBusiness rules

A useful test: can you compile and run the unit tests of the domain layer with the database, web framework, and message broker all uninstalled? If yes, the layering is doing its job.

8. A worked example

Take a small slice of an e-commerce fulfilment context: shipping an order. The use case is "mark this order as shipped, record the carrier and tracking number, and let other systems know".

The aggregate root, with the rules on the root rather than in a service:

// domain/order.ts
export class Order {
  private status: OrderStatus = "pending";
  private lines: OrderLine[] = [];
  private shipment?: Shipment;
  private events: DomainEvent[] = [];

  constructor(
    readonly id: OrderId,
    readonly customerId: CustomerId,
    readonly shipTo: Address,
  ) {}

  addLine(sku: Sku, qty: number, unitPrice: Money) {
    if (this.status !== "pending") throw new Error("order is closed");
    if (qty <= 0) throw new Error("qty must be positive");
    this.lines.push(new OrderLine(sku, qty, unitPrice));
  }

  total(): Money {
    return this.lines.reduce(
      (sum, l) => sum.add(l.unitPrice.times(l.qty)),
      Money.zero("GBP"),
    );
  }

  ship(carrier: Carrier, tracking: TrackingNumber, when: Date) {
    if (this.status !== "ready") throw new Error("not ready to ship");
    if (this.lines.length === 0) throw new Error("nothing to ship");
    this.shipment = new Shipment(carrier, tracking, when);
    this.status = "shipped";
    this.events.push(new OrderShipped(this.id, carrier, tracking, when));
  }

  pullEvents(): DomainEvent[] {
    const e = this.events;
    this.events = [];
    return e;
  }
}

Notice what the Order doesn't do: it doesn't know about databases, doesn't call APIs, doesn't write to a queue. It guards its own invariants and records that something happened.

The application service orchestrates the use case but contains no business rules:

// application/ship-order.ts
export class ShipOrderService {
  constructor(
    private orders: OrderRepository,
    private bus: DomainEventBus,
    private clock: Clock,
  ) {}

  async execute(cmd: ShipOrderCommand): Promise<void> {
    const order = await this.orders.findById(cmd.orderId);
    if (!order) throw new NotFound(cmd.orderId);

    order.ship(cmd.carrier, cmd.trackingNumber, this.clock.now());

    await this.orders.save(order);
    await this.bus.publish(order.pullEvents());
  }
}

And the repository is an interface in the domain layer; its real implementation lives in infrastructure and knows about Postgres or whatever you happen to be using. Other contexts react to the OrderShipped event without ever touching this aggregate directly. That's how cross-context consistency happens in practice: not through shared tables, but through facts crossing the boundary.

9. When it's worth the trouble

DDD is not free. The discipline costs time up front: workshops with the business, debates about names, refactors that move logic out of services and into entities, additional layers, additional indirection. On a four-week CRUD app, it's overkill. On a company's core revenue-producing system that will live for a decade and be touched by dozens of engineers, it's cheap.

The honest heuristic, after twenty years of people trying it:

The deeper claim of DDD, the one worth sitting with, is that the limit on most software is not technical at all. It's how clearly the team understands the work the software is supposed to do, and how faithfully that understanding gets into the code. The patterns are just scaffolding for that conversation. Once you've seen a codebase where the model and the business are the same thing, settling for less feels strange.

Primary source: Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). See also the DDD Reference (Evans, 2015) and Martin Fowler's notes on bounded context. Vaughn Vernon's Implementing Domain-Driven Design (2013) is the standard follow-up for the tactical patterns.