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.
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.
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.
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;
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 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;
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.
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;
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.
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;
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.
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;
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.
You find boundaries by listening, not by drawing boxes first. When any of these shows up, you are likely standing on a seam.
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;
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.
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;
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.
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;
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.
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.
| Pattern | Who adapts | Coupling | Use when |
|---|---|---|---|
| Conformist | downstream, fully | high | upstream is fixed and good enough; not worth translating |
| Anti-corruption layer | downstream, behind a translator | low | upstream is messy, legacy, or external and you must stay clean |
| Customer / Supplier | both, by agreement | medium | same org, downstream can influence upstream's roadmap |
| Open-host + published language | consumers adapt to a stable API | low | one context serves many; a public contract pays off |
| Shared kernel | shared, jointly owned | very high | two close teams, a tiny shared core, strong discipline |
| Separate ways | nobody, no link | none | the integration is not worth the cost |
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.
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;
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.
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;
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.
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;
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.
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;
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.
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;
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.
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.
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;
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.
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;
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."
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.
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.
| Block | What it is | The rule |
|---|---|---|
| Entity | has identity, changes over time | tracked by a stable id |
| Value object | defined by its value, no identity | immutable, interchangeable if equal |
| Aggregate | a cluster with one root | one aggregate, one transaction |
| Invariant | a rule that must always hold | defines where the aggregate boundary goes |
| Domain event | something meaningful happened | how contexts react without coupling |
| Repository | load and save aggregates | one repository per aggregate root |
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.
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.
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.
Take a system you know and answer out loud:
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.
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.