Domain-Driven Design (DDD)
Modeling complex business domains in code β entities vs value objects, aggregates that enforce invariants through a root, bounded contexts, and a ubiquitous language.
Domain-Driven Design tackles complex software by putting the business domain β not the database or framework β at the heart of the model. Its tactical building blocks (entities, value objects, aggregates, domain events) keep the model consistent and expressive, while its strategic patterns (bounded contexts, ubiquitous language) keep large systems and teams aligned around how the business actually works.
DDD puts the business domain at the center. You build a model in code that mirrors how domain experts think and talk β using a shared 'ubiquitous language' so the same words mean the same thing in conversation, code, and tests.
An Entity has a distinct identity that persists over time (an Order with an id). A Value Object is defined only by its attributes and is immutable (Money, an Address, a DateRange) β two are equal if their values match. Modeling values as immutable value objects eliminates a whole class of bugs.
An Aggregate is a cluster of related objects treated as one unit for changes, with a single Aggregate Root as the only entry point. All modifications go through the root, which enforces the aggregate's invariants β so the cluster is always in a consistent state and a transaction never spans two aggregates.
Large domains are split into Bounded Contexts β explicit boundaries within which a model and its language are consistent ('Customer' means something different in Sales vs Support). Contexts communicate through well-defined contracts and Domain Events (OrderConfirmed), keeping each model clean and decoupled.
Key Concepts
A shared vocabulary used by developers and domain experts alike β and reflected directly in the code's names.
An object with a persistent identity that's tracked over time, independent of its attribute values.
An immutable object defined solely by its attributes (Money, Address). Equality is by value, not identity.
The single entry point to an aggregate. All changes go through it so invariants and consistency are enforced.
An explicit boundary within which a domain model and its language are unambiguous and self-consistent.
A record that something meaningful happened in the domain (OrderConfirmed), used to react and integrate.
1// The aggregate root is the ONLY entry point β it guards invariants.2class Order { // aggregate root (entity)3 private lines: OrderLine[] = []; // child entities β never exposed raw4 private status = OrderStatus.Draft;56 // behavior, not setters β rules live with the data7 addLine(product: Product, qty: number) {8 if (this.status !== OrderStatus.Draft) throw new Error("order is locked");9 if (this.lines.length >= 5) throw new Error("max 5 lines");10 if (qty < 1) throw new Error("qty must be >= 1");11 this.lines.push(new OrderLine(product.id, qty, product.price));12 }1314 confirm() {15 if (this.lines.length === 0) throw new Error("empty order");16 this.status = OrderStatus.Confirmed;17 this.raise(new OrderConfirmed(this.id)); // domain event18 }1920 get total(): Money { // Money is a value object (immutable)21 return this.lines.reduce((s, l) => s.add(l.subtotal), Money.zero());22 }23}
DDD is the go-to approach for complex business software and pairs naturally with Clean Architecture, CQRS, and microservices (bounded contexts often map to service boundaries). Understanding aggregates and consistency boundaries is essential for both interview design questions and for building domains where the rules don't quietly rot into spaghetti.
Common Pitfalls
1The Order That Could Reach Invalid States
Services across the app load OrderLine rows directly and mutate quantities, totals, and status independently. Occasionally an order ends up confirmed but with a total that doesn't match its lines, or with a negative quantity.
Without an aggregate boundary, many code paths can change pieces of an order independently, and no single place enforces the rules. Invariants are violated because consistency was never owned by one object.
Make Order an aggregate root: OrderLines are internal, mutated only via Order methods (addLine, changeQty, confirm) that enforce every invariant in one place. Repositories load and save the whole aggregate as a unit, so it's never persisted in an inconsistent state.
Takeaway: Aggregates draw a consistency boundary. Funnel all changes through the root so invariants live in exactly one place β invalid states become unrepresentable rather than something every caller must remember to check.
2When 'Customer' Means Three Different Things
A growing system has one giant Customer class shared by sales, billing, and support. It has 60 fields, every team keeps adding to it, and a change for billing keeps breaking support.
A single model was forced to serve contexts with genuinely different needs and language. The shared 'God model' is highly coupled, and the same word ('customer') means different things to different teams, causing constant friction.
Split into bounded contexts: a lean Customer in Sales (leads, opportunities), an Account in Billing (invoices, payment terms), and a Contact in Support (tickets). Each context owns its model; they integrate via IDs and domain events rather than a shared class.
Takeaway: Don't force one model to mean everything. Bounded contexts let each part of the business have a model in its own language, decoupling teams and ending the tug-of-war over a shared God object.
Join the discussion
Comments
Loading comments...
Cookie preferences
We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.