Library
00/07 · ~36 min
GUIDEDECK · designing software around the business

Domain-Driven
Design & the model
at the heart of it.

A 36-minute working session on taming complex business software — from the ubiquitous language and bounded contexts, through aggregates and domain events, to the strategic call of when DDD is worth it and when a plain CRUD app quietly wins.

~36 MINBEGINNER → INTERMEDIATELANGUAGE-AGNOSTIC
SCROLL
01 · Why this matters 4 min

The hard part isn't the code —
it's the business the code models.

Most systems don't collapse under load; they collapse under misunderstanding. The team ships features fast for a year, then every change starts breaking three things nobody connected. The root cause is almost always the same: the code stopped matching how the business actually works. DDD is a set of tools for keeping those two in sync.

DDDDomain-Driven Design — is an approach where the structure and language of the code mirror the business domain it serves. The domainis the problem you're solving (lending, logistics, ticketing); the model is the simplified, agreed-upon picture of it that lives in your code. DDD is a way of thinking and collaborating, not a framework you install.

Two kinds of complexity

  • Essential— the genuine messiness of the business itself: the rules, edge cases, and vocabulary of insurance, shipping, or payroll. You can't code this away; you can only model it clearly.
  • Accidental — the complexity we add: frameworks, glue, clever abstractions, tangled layers. This you control.
  • DDD aims its effort at essential complexity — getting the model right — instead of polishing accidental plumbing.
RULES SCATTERED · fragile UI · rule controller SQL · rule job · rule trigger helper change one → hunt them all ONE MODEL · clear Domain model rules live here change one → change one place

When rules scatter, every change is a hunt. DDD pulls the business logic into one well-named model.

The symptom

Devs and the business use different words for the same thing — a "user" in code, a "policyholder" in the meeting. Translation errors pile up silently.

The cost

Logic ends up smeared across controllers, queries, and triggers. Onboarding takes months; small changes feel dangerous.

The bet

Invest in a shared model and language up front. It only pays off when the domain is genuinely complex — which is the honest catch we'll return to in Part 7.

02 · Ubiquitous language & the domain model 5 min

One language, spoken by
experts and code alike.

The single highest-leverage idea in DDD costs nothing to start: agree on the words. When the domain expert says "a quote expires", the code should have an expire() method on a Quote — not a status flag flipped by an update handler. Same word, same concept, everywhere.

Ubiquitous language a shared, rigorous vocabulary built by developers and domain experts together, and used identically in conversation, diagrams, and code. No silent translation layer between "what the business said" and "what we named the class". If the term changes in a meeting, the class gets renamed too.
WITHOUT · lossy translation expertpolicyholder developeruser_row meaning lost WITH · one shared term expert developer Policyholder same word in code no translation = no drift

Every silent translation between business and code is a place bugs and misunderstandings breed. The shared term removes the gap.

What a domain model actually is

  • Not a database schema and not a diagram on a wall — it's the set of concepts and rules, expressed in code, that the team agrees represents the business.
  • It's deliberately simplified: a model keeps what matters for the problem and drops the rest. A map is not the territory.
  • It builds on ordinary object-oriented modeling — classes that bundle data with the behavior that protects it.
Anemic model — data with no rules
class Order { status: string; lines: Line[] } // just a bag of fields // rules live elsewhere, scattered and unguarded: function ship(o: Order) { if (o.status !== "paid") throw Error("not paid") o.status = "shipped" // anyone, anywhere, can flip this }
Rich model — rules live with the data
class Order { private status = "pending" ship() { // the behavior IS the language if (this.status !== "paid") throw new Error("cannot ship an unpaid order") this.status = "shipped" } } // invalid transitions are impossible from outside

Like a glossary the whole team signs: when everyone — sales, support, engineering — means exactly the same thing by "shipped", the arguments stop being about words and start being about the work.

03 · Bounded contexts & context mapping 6 min

One model can't mean everything.
Draw boundaries around it.

Try to make a single Customerclass serve sales, support, and billing and you get a monster with forty fields that's wrong for all three. The fix isn't a bigger model — it's several smaller ones, each correct inside its own boundary. A word is only allowed one meaning per context.

Bounded context an explicit boundary within which a particular domain model and its ubiquitous language are consistent and unambiguous. The same word can mean different things in different contexts — that's fine, as long as each context owns its own model and they translate at the edges.
one word · three contexts · three models Sales context Customer = · a Lead with a pipeline · quotes, probability · no payment data owns: Lead, Quote Support context Customer = · an Account with tickets · SLA, history · no pipeline owns: Ticket, SLA Billing context Customer = · a Payer with invoices · tax id, terms · no tickets owns: Invoice, Payment

"Customer" is a different model in each context. Forcing them into one shared class is the classic mistake.

Context mapping

Once you have multiple contexts, you map how they relate. A context map is the big-picture diagram of your contexts and the integration relationship on each boundary — who depends on whom, and who translates.

This is where DDD meets system architecture: a bounded context is the natural seam to split a service along. The mechanics of those splits live in Architecture Patterns & Styles.

Sales Support Billing Identity U→D U→D shared

A context map: arrows point upstream → downstream (U→D). Identity is a shared kernel both depend on.

The integration relationships

P
Partnership / Shared Kernel
Two contexts succeed or fail together.
+

Partnership — two teams coordinate closely and evolve their contexts in lockstep. Shared kernel — they share a small, jointly-owned slice of model (e.g. a common Money or UserId). Powerful but expensive: any change needs both teams to agree, so keep the shared part tiny.

C
Customer / Supplier
Downstream has a real voice with upstream.
+

The upstream context provides something the downstreamone needs (Sales feeds Billing). It's a healthy relationship when the downstream team's needs are negotiated into the upstream team's backlog — supplier serves customer.

F
Conformist
Downstream just accepts the upstream model.
+

When the upstream won't bend — a payment processor, a giant internal platform — the downstream simply conforms to its model. Cheap and pragmatic, but you inherit their language and their quirks. Fine for generic concerns; risky for your core domain.

A
Anti-Corruption Layer
Translate at the border to protect your model.
+

When you must integrate with a messy or foreign model but refuse to let it leak in, you build an anti-corruption layer— a translation boundary that converts their concepts into yours. We give it real code in Part 6; it's the most valuable defensive pattern in DDD.

04 · Aggregates, entities & value objects 6 min

The building blocks that
keep a model consistent.

Inside a bounded context, the model is made of three kinds of object. Get these distinctions right and your invariants — the rules that must always hold — enforce themselves. Get them wrong and you're back to scattered validation and corrupt data.

Entity

Defined by identity

Has a unique id that persists through change. Two orders with identical contents are still different orders. Think Order, Customer, Shipment.

Value object

Defined by its values

No identity, immutable, interchangeable. Two Money(5, "USD") are equal and swappable. Think Money, Address, DateRange.

Aggregate

A consistency boundary

A cluster of entities and value objects treated as one unit, with a single root as the only entrance. Rules inside it stay true together.

// VALUE OBJECT — equal by value, immutable class Money { constructor(readonly amount: number, readonly currency: string) {} add(o: Money): Money { // returns a NEW one return new Money(this.amount + o.amount, this.currency) } } // ENTITY — equal by identity, mutable over time class Order { constructor(readonly id: OrderId) {} }
Order aggregate Order · root OrderLine OrderLine Money · value object enter via root only Customer by id, not ref

Outside code talks only to the root. Lines and Money are guarded inside; other aggregates are referenced by id, not held directly.

The aggregate root's job

  • It's the only way in. You never reach past it to poke a child object — that's how invariants survive.
  • It enforces rules across its whole cluster: "an order's total must equal the sum of its lines" can only be guaranteed if lines change through the root.
  • It's the unit of persistence and transactions — you load and save a whole aggregate at once (Part 6).

Keep aggregates small

  • A common beginner mistake is one giant aggregate holding half the domain. It becomes a lock-contention and performance nightmare.
  • Rule of thumb: an aggregate should hold only what must stay consistent in the same instant. Everything else references by id and reconciles via events.
  • Across aggregates you get eventualconsistency — and that's usually fine.

Like a shopping cart: you add and remove items through the cart, never by editing a stranger's line item directly. The cart is the root; it keeps the total honest.

05 · Domain events & event storming 5 min

Capture what happened,
then let the model react.

Businesses think in events: an order was placed, a payment was received, a shipment was dispatched. Making those facts first-class in your model — as domain events — keeps your aggregates small and your contexts loosely coupled, because reactions move out of the aggregate that caused them.

Domain event a record of something meaningful that happened in the domain, named in the past tense and immutable once it occurs (OrderPlaced, PaymentReceived). The aggregate that owns the change records it; other parts of the system subscribe and react — often in another bounded context.
// a fact: past tense, immutable, named in the language class OrderPlaced { constructor( readonly orderId: OrderId, readonly placedAt: Date, ) {} } // the aggregate root records it as part of placing: order.place() // → emits OrderPlaced // Billing, Email, Analytics each react on their own.
Order root place() OrderPlaced event Billing Email Analytics

The aggregate records the fact; subscribers react. The transport — a broker — is a separate concern.

Domain events are a modeling idea; how they travel between services — brokers, partitions, delivery guarantees — is the subject of Message Queues & Streaming. A domain event inside one process can simply be an in-memory notification.

Event storming

Event storming is a fast, low-tech workshop: get domain experts and developers around a wall and map the business as a timeline of events on sticky notes. Orange notes are events; blue are the commands that cause them; yellow are the aggregates they land on; pink are external systems. In an afternoon you discover the real process, the language, and — crucially — where the bounded-context seams fall.

time → PlaceOrder command Order aggregate OrderPlaced event Payments

Command → aggregate → event, left to right on a timeline. Colors map note types; the gaps reveal context boundaries.

06 · Tactical patterns & the toolchain 6 min

The patterns that keep the
model pure and the edges out.

Strategic design draws the boundaries; tactical patterns implement them. Three earn their keep on almost every DDD project — they keep persistence, cross-aggregate logic, and foreign models from leaking into your clean domain.

Repository

Persist whole aggregates

A collection-like interface that loads and saves an aggregate as one unit. The domain speaks OrderRepo — never SQL or an ORM.

Domain service

Logic that fits no entity

When a rule spans several aggregates and belongs to none, put it in a stateless domain service — named in the ubiquitous language, not a junk-drawer Manager.

Anti-corruption layer

Translate at the border

A boundary that converts a foreign or legacy model into yours, so their concepts never pollute your domain.

interface OrderRepo { // a collection-like illusion findById(id: OrderId): Order save(order: Order): void // the whole aggregate } // domain code depends on OrderRepo — not on SQL. // SqlOrderRepo / InMemoryOrderRepo implement it, // so the model stays testable and persistence-agnostic.
our domain clean model ACL translate legacy CRM foreign model their mess stops at the border

The anti-corruption layer converts the legacy CRM's shape into clean domain terms — its quirks never cross the border.

The repository is dependency inversion applied to your data layer; the ACL is the Adapter pattern guarding a whole context. DDD reuses the same class-level tools you already know — it just points them at the domain.

Tooling landscape — where DDD fits

DDD is a modeling discipline, not a library you import. These are the places it touches the rest of your toolchain — pick by what you actually need, and remember most projects need none of the heavy ones.

The real work is conversation. These just give the workshop a surface.

Miro / Mural

Pro — infinite shared canvas for remote event storming; sticky notes, voting, templates.

Con — paid seats; can sprawl without facilitation.

Choose for distributed teams running recurring workshops.

Excalidraw

Pro — free, instant, low-ceremony; perfect for a quick context map or first storm.

Con — fewer workshop features (voting, timers, big templates).

Choose for lightweight sketching and ad-hoc diagrams.

A physical wall

Pro — highest bandwidth; co-located teams move fastest with real sticky notes.

Con — no record unless you photograph it; needs everyone in a room.

Choose when the team is in one place — still the gold standard.

DDD's boundaries map cleanly onto system-level shapes — these decks own the mechanics.

Bounded context → service

A bounded context is the natural seam to split a microservice along — language and ownership that change together.

See Architecture Patterns for monolith → service trade-offs.

Domain events → broker

Domain events become integration events crossing contexts over a message broker.

See Message Queues & Streaming for delivery and ordering.

Frameworks (optional)

Tools like Axon (JVM) or an event-store database help with event sourcing & CQRS — but they are an implementation choice, never a prerequisite.

Choose only when event sourcing already earns its keep.

07 · Strategic design & when NOT to use DDD 4 min

Spend your modeling effort
where it actually pays.

The most senior DDD skill isn't aggregates — it's knowing which parts of your system deserve the full treatment and which should stay a boring CRUD form. That call is strategic design, and sometimes the answer is "don't use DDD here at all."

Core domain

Your competitive edge

The part that makes the business special — the pricing engine, the matching algorithm. Invest your best people and full DDD here.

Supporting

Necessary, not special

Needed for the core to work but not a differentiator. Model it lightly — enough structure, no gold-plating.

Generic

Solved by everyone

Auth, notifications, billing rails. Buy or use off-the-shelf— don't lovingly hand-model what a SaaS already does.

When NOT to use DDD. If the domain is simple — a form over a database, mostly create / read / update / delete with few real rules — DDD is pure overhead. Aggregates and contexts add ceremony with no payoff. The honest default: reach for DDD only when essential complexity is high and the domain is core to the business. Otherwise a plain CRUD app wins.
1Model the language first. A shared, rigorous ubiquitous language is the cheapest, highest-leverage practice in all of DDD.
2One model per bounded context.Let a word mean different things in different contexts; translate at the borders, don't unify.
3Aggregates guard invariants. Small, root-only access, consistent in the instant; everything else by id and events.
4Protect the model at the edges. Repositories hide persistence; anti-corruption layers keep foreign models out.
5Spend effort on the core. Full DDD for the differentiator; light modeling for support; buy the generic — and skip DDD entirely when the domain is simple.

A 60-second gut check

  • Lots of tangled business rules nobody fully understands? → Start with the ubiquitous language and one event-storming session. That alone is worth it.
  • One model serving three departments badly? → Split into bounded contexts before you split any services.
  • Mostly forms over a database, few real rules? → Skip the tactical machinery. Use a simple layered CRUD app.
  • Tempted by aggregates and CQRS on day one? → Resist. Adopt patterns when the pain arrives, not before — same YAGNI discipline as everywhere else.
Knowledge check

Did it stick?

Five quick questions on language, contexts, aggregates, events, and the strategic call — instant feedback, no sign-in.

Rate this deck
be the first

Navigate with ← → or scroll · back to library