Session 8 · Fri 26 June 2026 · 90 min

Domain-Driven Design for Architects.

Last session said: split on a real seam. Tonight is how you find the seam. Domain-driven design is the toolkit for cutting a system along the meaning of the business, not the lines of the org chart.

Ehsan Gazar
From Senior to Staff · session 8 of 12
Where we're going · 90 minutes

The run sheet.

0–12Why DDD matters to designlanguage, subdomains, the two halves
12–35Bounded contextswhere one word stops meaning one thing
35–52Context mappinghow contexts relate, anti-corruption
52–75Tactical building blocksaggregates, invariants, domain events
75–85Contexts to servicesturning seams into ADR #2
85–90Event storming + exercisefind a boundary live
By the end of tonight

You'll be able to…

1Find bounded contexts in a messy domain by watching the language.
2Draw a context map and protect a model with an anti-corruption layer.
3Use aggregates and invariants as your unit of consistency.
4Turn a context boundary into a service or a module, and justify it.
The one big idea

The best service boundary is a domain boundary.

A "customer" means something different to sales, to billing, and to shipping. Where the meaning of a word changes, you have found a natural boundary, and that seam is where a piece can split cleanly without constant cross-talk. DDD is how you find those seams on purpose.

💡 In plain English

The word "customer" means different things to the sales team, the support team, and the shipping team. Draw your dividing lines exactly where a word changes meaning, and the pieces barely need to talk to each other.

Same word, different model
that is a context boundary, not a shared table
signal
Ubiquitous language
code and conversation use the exact same terms
discipline
Seam becomes a service
split on the seam and integrations stay thin
payoff
Why DDD matters · 0–12 min

DDD has two halves. As an architect you live in the first.

Strategic DDD is about boundaries: where to cut the system. Tactical DDD is about the building blocks inside one piece. Tonight is mostly strategic, because that is what decides your architecture, but we will cover enough tactical to make the boundaries real.

flowchart TB
  DDD["Domain-Driven Design"] --> STRAT["Strategic DDD
the big boundaries"] DDD --> TACT["Tactical DDD
building blocks inside"] STRAT --> S1["subdomains"] STRAT --> S2["bounded contexts"] STRAT --> S3["context maps"] TACT --> T1["entities · value objects"] TACT --> T2["aggregates · invariants"] TACT --> T3["domain events"] classDef root fill:#EDE9FE,stroke:#1F2937,color:#0E1726; classDef strat fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef tact fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class DDD root; class STRAT,S1,S2,S3 strat; class TACT,T1,T2,T3 tact;
💡 In plain English

Strategic is town planning, where the districts go. Tactical is the building code inside one district. You need both, but as an architect, getting the districts right matters far more than the wallpaper.

The problem DDD solves

The model in the code drifts from the business.

The business says "policy," the code says "record," the database says "row." Each translation loses meaning, and over years the software stops matching how the business actually thinks. Bugs are often just this gap, the code modelling something subtly different from what was meant.

flowchart LR
  BIZ["business experts
say policy"] -.->|"meaning lost in translation"| CODE["the code
says record"] CODE -.->|"gap widens over years"| BUG["features quietly miss
what the business meant"] classDef biz fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class BIZ biz; class CODE,BUG bad;
💡 In plain English

Like a game of telephone between the business and the code. Every hand-off mangles the message a little, until the software is solving a slightly different problem than the one the company actually has.

The cheapest, most powerful tool

Ubiquitous language: one word, one meaning, everywhere.

Pick the words with the domain experts and then use those exact words in conversation, in the code, in the tables, in the API. No translation layer in anyone's head. It sounds trivial; it is the single highest-leverage habit in DDD.

flowchart LR
  EXP["domain experts"] --> LANG["one shared language
same words for the same things"] DEV["developers · the code"] --> LANG LANG --> MODEL["a model everyone
can talk in and build in"] classDef p fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef l fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef m fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class EXP,DEV p; class LANG l; class MODEL m;
💡 In plain English

If the business says "policy" but your class is called InsuranceRecord, rename the class. When the words match, a domain expert can read your method names and tell you where the bug is. That is the whole point.

Where to spend your best people

Not all of the domain is worth the same effort.

Split the domain into three kinds. Core is your competitive edge, build it well, by hand. Supporting is necessary but not special. Generic is a solved problem, buy it or use something off the shelf. Spending your best engineers on a generic subdomain is a classic waste.

flowchart TB
  D["your whole domain"] --> CORE["Core subdomain
your edge, build it well"] D --> SUPP["Supporting subdomain
needed, not special"] D --> GEN["Generic subdomain
buy it or use off the shelf"] classDef d fill:#EDE9FE,stroke:#1F2937,color:#0E1726; classDef core fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef supp fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef gen fill:#DCEBFE,stroke:#1F2937,color:#0E1726; class D d; class CORE core; class SUPP supp; class GEN gen;
💡 In plain English

For a bank, fraud detection is core, build it. The staff scheduling is supporting, build it simply. Email and auth are generic, never write your own. Knowing which is which tells you where to put your strongest people.

Bounded contexts · 12–35 min

A bounded context is where one model holds true.

The key insight of DDD: you do not need one giant model of "Customer" for the whole company. Inside each context the word means one specific thing, with its own data and rules. The boundary is exactly where the meaning changes.

flowchart TB
  W["the word: Customer"] --> S["Sales context
a lead with a deal in progress"] W --> B["Billing context
an account with invoices"] W --> SH["Shipping context
an address to deliver to"] classDef w fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef c fill:#DCEBFE,stroke:#1F2937,color:#0E1726; class W w; class S,B,SH c;
💡 In plain English

One "Customer" table shared by everyone becomes a Frankenstein with fifty columns nobody fully understands. Three contexts, each with its own small, sharp idea of a customer, is cleaner and lets the three teams move independently.

How to actually spot one

Four signals that you are crossing a context boundary.

You find boundaries by listening, not by drawing boxes first. When any of these shows up, you are likely standing on a seam.

The same word changes meaning
"order" to sales is a quote; to the warehouse it is a picking list
language
Different people own it
a different team or expert answers questions about it
people
Different rate of change
one part churns weekly, the other is stable for years
change
Different lifecycle
the data is created, used, and retired on its own clock
lifecycle
Context mapping · 35–52 min

A context map shows how the contexts relate.

Once you have contexts, you draw the lines between them: who sends what to whom. The map is not boxes-and-arrows decoration, it tells you where the integration risk is and which team depends on which.

flowchart LR
  SALES["Sales"] -->|"places orders"| ORDERS["Orders"]
  ORDERS -->|"requests a charge"| BILLING["Billing"]
  ORDERS -->|"requests a shipment"| SHIP["Shipping"]
  classDef c fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  class SALES,ORDERS,BILLING,SHIP c;
        
💡 In plain English

It is the map of who talks to whom, and in which direction. The moment you can see it, you can see which relationship would hurt most if it broke, and that is where you spend your design care.

The direction matters

Upstream defines the contract. Downstream has to cope.

Every relationship has a power direction. The upstream context sets the model and changes on its own schedule; the downstream context has to adapt to whatever upstream sends. Knowing who is which tells you who absorbs the pain when things change.

flowchart LR
  UP["Upstream context
defines the contract"] -->|"its model flows down"| DOWN["Downstream context
must adapt to it"] DOWN --> Q{"how does downstream
protect itself?"} Q --> CONF["Conformist:
just accept their model"] Q --> ACL["Anti-corruption layer:
translate at the border"] classDef up fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef down fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef q fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class UP up; class DOWN down; class Q q; class CONF bad; class ACL up;
💡 In plain English

If you depend on a team that changes things without warning, you are downstream and you will feel every change. Either bend to their model (conformist) or build a translator at your door so their mess stays outside.

The staff-level detail

The anti-corruption layer: a translator at the border.

When you integrate with a legacy system or a messy external model, do not let its shapes leak into your clean model. Put a thin translation layer at the boundary that converts their concepts into yours. Their changes stop at the wall, not in your core.

flowchart LR
  EXT["legacy / external context
messy foreign model"] --> ACL["anti-corruption layer
translates at the border"] ACL --> MINE["your clean model
stays uncorrupted"] classDef ext fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef acl fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef mine fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class EXT ext; class ACL acl; class MINE mine;
💡 In plain English

Like a translator at an embassy. You never learn the other side's confusing dialect, you speak to the translator in your own language and they handle the mess. When they reorganise, only the translator changes.

The vocabulary of a context map

The ways two contexts can relate.

1
Customer / Supplier
downstream needs are negotiated; upstream agrees to support them
2
Conformist
downstream just adopts the upstream model, no negotiation
3
Anti-corruption layer
downstream translates at the border to stay clean
4
Open-host + published language
upstream offers a stable public contract for many consumers
5
Shared kernel
two contexts share a small common model, with care
6
Separate ways
no integration at all; the cheapest option when you can
💡 In plain English

These are just named answers to "how do two parts of the business deal with each other." You do not need to memorise all six; you need to recognise which one you are in, and whether it is the one you want.

Choosing a relationship

Which integration pattern, and when.

PatternWho adaptsCouplingUse when
Conformistdownstream, fullyhighupstream is fixed and good enough; not worth translating
Anti-corruption layerdownstream, behind a translatorlowupstream is messy, legacy, or external and you must stay clean
Customer / Supplierboth, by agreementmediumsame org, downstream can influence upstream's roadmap
Open-host + published languageconsumers adapt to a stable APIlowone context serves many; a public contract pays off
Shared kernelshared, jointly ownedvery hightwo close teams, a tiny shared core, strong discipline
Separate waysnobody, no linknonethe integration is not worth the cost
Tactical building blocks · 52–75 min

Inside a context: the building blocks.

Now we zoom in. Inside one bounded context, DDD gives you a small set of building blocks for modelling the domain in code. You do not need all of them everywhere, but two, aggregates and domain events, directly shape your architecture.

Entity
has identity, changes over time
Value object
no identity, defined purely by its value
Aggregate
the consistency boundary, one root
Domain event
something meaningful that happened
Repository
load and save aggregates
Domain service
logic that fits no single entity
The first distinction

Entities have identity. Value objects do not.

An entity is the same thing over time even as its fields change, tracked by an id (a user, an order). A value object has no identity; it is fully described by its value and is interchangeable (money, an address, a date range). Modelling values as objects, not loose fields, removes a surprising amount of bugs.

flowchart LR
  E["Entity
identity, changes over time"] --> EX["a User · an Order
same id, evolving fields"] V["Value object
no identity, defined by value"] --> VX["Money · Address · DateRange
interchangeable if equal"] classDef e fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef v fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class E,EX e; class V,VX v;
💡 In plain English

Your bank account is an entity, same account even as the balance changes. A £5 note is a value object, any £5 note is as good as any other. Treating "money" as a real type instead of a bare number stops a whole class of rounding and currency bugs.

The most important block for architects

An aggregate is a cluster you treat as one unit.

Some objects only make sense together: an order and its line items. Group them into an aggregate with a single root. The outside world only ever touches the root, never the inside parts directly. The aggregate is the unit you load, save, and keep consistent.

flowchart TB
  subgraph AGG["Order aggregate"]
    ROOT["Order
aggregate root"] --> LI1["line item"] ROOT --> LI2["line item"] ROOT --> ADDR["shipping address"] end OUT["outside code"] -->|"only ever touches the root"| ROOT classDef root fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef part fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef out fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class ROOT root; class LI1,LI2,ADDR part; class OUT out;
💡 In plain English

The order is the box; the line items live inside it. You do not hand someone a single line item from inside the box and let them edit it loose. You go through the order, which keeps the whole box sensible.

Three rules that keep aggregates sane

One aggregate, one transaction.

These three rules are where DDD meets last week's distributed-systems reality. Follow them and your consistency boundaries line up with your transactions.

flowchart LR
  R1["one aggregate =
one transaction"] --> OK["consistency stays
inside the boundary"] R2["reference other aggregates
by id, not by object"] --> OK R3["keep aggregates small"] --> OK classDef r fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class R1,R2,R3 r; class OK ok;
💡 In plain English

Change one box per save. If you need to change two boxes, that is two steps, and across services it becomes a saga from S6, not one transaction. A giant aggregate that holds half the system is the classic mistake; keep the box small.

Why the boundary exists at all

An aggregate exists to protect an invariant.

An invariant is a rule that must always be true: an order total cannot exceed the credit limit; a seat cannot be booked twice. The aggregate is drawn exactly around the data needed to enforce that rule in one atomic step. The rule is the reason for the boundary.

flowchart TB
  CMD["command: add line item"] --> AGG{"Order aggregate
checks the invariant"} AGG -->|"total within credit limit"| OK["accept the change"] AGG -->|"would break the rule"| NO["reject the change"] classDef cmd fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef agg fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef no fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class CMD cmd; class AGG agg; class OK ok; class NO no;
💡 In plain English

Ask "what rule must never be broken, even for a moment?" Whatever data you must look at to enforce that rule belongs in the same aggregate. If you find yourself needing two aggregates to check one rule, your boundary is wrong.

How contexts talk without coupling

Domain events: announce what happened in the language of the domain.

When something meaningful happens, an aggregate emits a domain event: OrderPlaced, PaymentReceived. Other contexts subscribe and react. This is how bounded contexts stay decoupled, and it connects straight to the event-driven style and the outbox from S6 and S7.

flowchart LR
  AGG["Order aggregate"] -->|"emits"| EV["OrderPlaced
a domain event"] EV --> C1["Billing reacts"] EV --> C2["Shipping reacts"] EV --> C3["Email reacts"] classDef agg fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef ev fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef c fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class AGG agg; class EV ev; class C1,C2,C3 c;
💡 In plain English

The order context shouts "order placed" and forgets about it. Billing, shipping and email each decide what to do. The order context does not know or care who is listening, which is exactly the loose coupling you want between contexts.

Where the rest of the logic lives

Three more blocks, so logic lands in the right place.

Domain service
a domain operation that does not belong to any one entity, e.g. "transfer money between two accounts". Pure domain logic, no plumbing.
Application service
orchestrates a use case: load the aggregate, call it, save it, publish events. Thin, no business rules of its own.
Repository
the collection-like way to load and save whole aggregates, hiding the database behind the domain.
💡 In plain English

Business rules live in entities and aggregates. The application service is just the waiter: it takes the order, fetches the right box, asks it to do the work, and saves it. Keep the rules in the domain, keep the waiter dumb.

The fastest way to discover all this

Event storming: find the model on a wall of sticky notes.

Get the domain experts and engineers in one room and map the business as a timeline of events. Then add the commands that cause them and the aggregates they act on. Contexts emerge naturally as clusters. It is the single best technique for finding boundaries fast.

flowchart LR
  EV["domain events
OrderPlaced"] --> CMD["commands
PlaceOrder"] CMD --> AGG["aggregates
Order"] AGG --> BC["bounded contexts
clusters that emerge"] classDef ev fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef cmd fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef agg fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef bc fill:#DCEBFE,stroke:#1F2937,color:#0E1726; class EV ev; class CMD cmd; class AGG agg; class BC bc;
💡 In plain English

Sticky notes on a wall: orange for "what happened," blue for "what triggered it," yellow for "the thing it happened to." Within an hour the natural groupings on the wall are your bounded contexts. No upfront diagramming, just the business out loud.

Contexts to services · 75–85 min

A bounded context is the cleanest possible service.

This is the payoff and the link back to S7. A context already owns its model and its data and talks to others through thin, explicit contracts. So when a real axis justifies a split, the context boundary is exactly where you cut: one context becomes one service with its own database.

flowchart LR
  BC["Billing
bounded context"] --> SVC["Billing service
own process, own DB"] SVC --> DB[("billing database")] SVC -->|"domain events"| OTHERS["other services subscribe"] classDef bc fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef svc fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef db fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class BC bc; class SVC,OTHERS svc; class DB db;
💡 In plain English

Because a context already has real walls and owns its own data, lifting it out into a service is a clean cut, not a painful tear. This is why "find the context first" beats "draw services on the org chart."

The S7 discipline still applies

A context can be a service, or just a module.

Finding a bounded context does not mean you must deploy it as a service. A context is a logical boundary; whether it becomes a separate deployable is still the S7 decision, driven by a real axis. Most contexts should start as modules in a modular monolith.

Context as a module
enforced boundary inside one deployable: the default
start here
Context as a service
when scaling, change rate, or failure genuinely diverge
earn it
Context split mid-model
cutting through an aggregate: distributed-monolith pain
avoid
💡 In plain English

DDD tells you where the lines are. S7 tells you whether to build walls or roads along them. Find the context, then choose module or service with the same "name the axis" test as last week.

The tactical blocks on one card

Building blocks, side by side.

BlockWhat it isThe rule
Entityhas identity, changes over timetracked by a stable id
Value objectdefined by its value, no identityimmutable, interchangeable if equal
Aggregatea cluster with one rootone aggregate, one transaction
Invarianta rule that must always holddefines where the aggregate boundary goes
Domain eventsomething meaningful happenedhow contexts react without coupling
Repositoryload and save aggregatesone repository per aggregate root
The honest caveat

DDD earns its cost only where the domain is complex.

DDD is an investment. On a genuinely complex core domain it pays for itself many times over. On a simple CRUD app or a generic subdomain, the ceremony is pure overhead. The senior move is applying it where the complexity is, not everywhere.

Worth it
rich rules, many edge cases, a core domain that is your edge
invest
Maybe just the language
moderate complexity: take ubiquitous language and bounded contexts, skip the rest
lite
Overkill
simple CRUD, generic subdomain: aggregates and ACLs are just friction
skip
💡 In plain English

You do not bring the full toolkit to a to-do list. Ubiquitous language and bounded contexts are cheap and almost always worth it; the heavy tactical machinery is for the gnarly core, where getting the model right is the whole game.

Traps that look like DDD

Five ways teams misuse it.

Anemic domain model
entities are just data bags; all the logic sits in services. You named things DDD but kept procedural code.
no behaviour
The god aggregate
one huge aggregate holding half the system, so every change contends on it
too big
One canonical model
forcing every team onto one shared "Customer", the thing bounded contexts exist to avoid
no boundary
DDD everywhere
full tactical ceremony on a generic CRUD subdomain that needed none
overkill
💡 In plain English

The most common one is the anemic model: classes that are pure data with all behaviour elsewhere. That is the procedural code you already had, wearing DDD vocabulary. If your entities have no methods that enforce rules, you are not doing DDD.

Your turn · live

Find a boundary in your own domain.

Take a system you know and answer out loud:

1Name one word that means two different things to two parts of the business.
2That is a context boundary. What are the two models on each side?
3Pick one rule that must always hold: what aggregate protects it?
4Would you make that context a module or a service, and what is the axis?
💡 In plain English

Question one is the whole skill in miniature. If you can name a word that two teams use differently, you have found a real seam, and that seam is the heart of your ADR #2.

Recap · then what's next

Split on meaning. Own the data. Protect the model.

Watch the language to find bounded contexts. Map how they relate and use an anti-corruption layer to keep a clean model. Use aggregates and invariants as your consistency units, and let context boundaries, not the org chart, decide where services go.

Finish · Project 3 ADR #2
Due Jul 3. Justify a service boundary with a context boundary, the strongest argument you can make.
the deliverable
Take-home
Find one word that means two things in your domain. That is a boundary. Bring it.
bring it