Domain-Driven Design for Beginners: What It Is and Why It Matters
Most software doesn’t break because of syntax errors or flawed if-else logic. It breaks because teams lose alignment with the business problem they’re supposed to solve.
Domain-Driven Design (DDD) has a reputation for being academic — a term you hear in architecture meetings, attached to diagrams full of circles and arrows, that never quite turns into something you can use on Monday morning. This post takes the opposite approach: everything is built up from one real, named business, from scratch, in plain English, before any code shows up. Every pattern is introduced with the actual bug it prevents, and the post closes by naming the confusions that trip up almost everyone learning DDD for the first time — one language or many, one database or many, domain vs. bounded context — and answers each one directly.
This is the first of three posts. This one covers the foundations. The deep dive continues the same example into Aggregates, Domain Events, Repositories, and Context Mapping — including the “one database or many?” question, which is really a deep-dive-level architecture decision. The third post drops the fictional example entirely and walks through a real build.
A companion presentation deck (same example, built for a walkthrough) is available here.
Meet Northwind
Northwind is a small online retail business. Three people run the whole thing. In plain English, here is everything Northwind does — no code, no database, no framework, just the business:
- People browse a catalog and place orders.
- Someone decides what things cost, and runs the occasional discount.
- Someone keeps track of what’s actually in the warehouse.
- Someone packs boxes and hands them to a carrier.
- Someone charges the customer’s card and refunds it when something goes wrong.
- Someone lets a customer log in and see their order history.
That list — nothing more than what the founders would say if you asked “what does Northwind actually do?” — is the business domain. Not a diagram, not a database schema. Just the real-world activity the software is eventually going to support. Everything below is this same list, gradually made precise enough to build software against.
Terms at a Glance
Five words get used interchangeably by beginners (and, honestly, by a lot of blog posts) even though they mean distinct things. Bookmark this table — the rest of the post explains each one, using Northwind as the running example.
| Term | What it means | Northwind example |
|---|---|---|
| Domain | The real-world subject matter the software exists to serve, independent of any code | “Online retail” |
| Business domain | The specific domain your company operates in | Northwind’s business domain is online retail |
| Subdomain | A distinct area of responsibility inside the business domain | Order Management, Inventory, Payments, Fulfillment, Auth, Pricing |
| Bounded Context | The boundary you draw in code where one model and one vocabulary apply — usually one context per subdomain | The order-management module/service, with its own Product and Order |
| Ubiquitous Language | The shared vocabulary used inside one bounded context — scoped to that context, not the whole company | Inside Order Management, “Product” always means a purchasable line item |
The short version: domain and subdomain describe the business; bounded context and ubiquitous language describe how you organize the code and the words around it. Keep those two groups separate in your head and most DDD confusion evaporates.
The Bug: Two Teams, One Word, Two Meanings
Northwind grew. A few years in, there’s an Ordering team and a separate Inventory team, each shipping its own service. Both use a type called Product. Neither team thinks there’s a problem.
// InventoryService — "price" is the wholesale cost Northwind pays the supplier
class Product { sku: string; price: number; stockCount: number; }
// OrderService — imports the SAME Product type from a shared "models" folder
// and charges the customer this "price" directly
function calculateTotal(product: Product, qty: number) {
return product.price * qty; // customer is charged supplier COST, not retail price
}
Nobody wrote a bug, exactly. Both teams used a perfectly reasonable field named price. The system still shipped a real one: customers were charged Northwind’s own wholesale cost for weeks before anyone noticed margins had quietly collapsed.
There was no agreement on what “Product” — or “price” — actually means, and nothing in the codebase stopped one team’s meaning from leaking into another team’s logic. This is the exact failure mode Domain-Driven Design exists to prevent.
Why This Keeps Happening
Early-stage systems rarely have this problem, and that’s worth noticing: it’s not a maturity issue, it’s a scale issue.
- Early Northwind succeeded because the three founders held the entire mental model of the business in their heads. There was no room for “Product” to mean two things — there was only one small group deciding what it meant.
- Later Northwind failed because that model fractured. The Ordering and Inventory teams grew apart, terminology drifted between groups that rarely talked to each other, and the code became a progressively worse translation of what the business actually does.
- Features got bolted on without anyone stepping back to ask whether the existing model still made sense. Every new requirement piled another special case onto assumptions that were already stale.
This is rarely a tooling problem. Better linters and better frameworks would not have caught the wholesale/retail bug — it’s a modeling problem, and that’s exactly the moment DDD targets. As ByteByteGo puts it in their explainer on the topic: the hardest part of a complex product was never the codebase. It’s agreeing on what the business is actually doing.
What Is Domain-Driven Design?
The term comes from a specific place: Eric Evans’ 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. Every tactical pattern in this post and the next — Entity, Value Object, Aggregate, Domain Event, Repository — traces back to that book by name; nothing here is a reinterpretation.
Domain-Driven Design is an approach to software design that puts the business domain — not the database schema, not the framework du jour — at the center of every decision. It requires engineers to collaborate deeply and continuously with domain experts (the people who actually run Northwind), not just gather requirements once at kickoff and disappear into a backlog.
A few things worth being upfront about:
- It’s not a silver bullet. DDD doesn’t generate code, and it won’t magically fix a legacy monolith by itself.
- What it gives you instead is clarity about what the system is supposed to do, and where it’s actually safe to change something without breaking an unrelated part of the business.
- It’s architecture-agnostic. DDD works whether Northwind is a single monolith or a constellation of microservices — what matters is whether the model reflects real domain rules, and whether that model can evolve safely as the domain changes.
How to Actually Identify Northwind’s Subdomains
Go back to the plain-English list from the very first section. Turning it into subdomains is a repeatable, four-step method — not guesswork, and not something you get by staring at database tables:
- List what the business actually does, in the founders’ own words. (Already done above: browsing/ordering, pricing, inventory, fulfillment, payments, accounts.)
- For each item, ask: does a shared word change meaning here? “Product” means something different to whoever’s counting warehouse stock versus whoever’s building a shopping cart. That shift in meaning is a signal you’ve found a boundary.
- Ask: does a different group of people make the decisions here, on a different rhythm? Pricing changes daily with promotions; stock counts sync hourly from the warehouse; login barely changes at all once it’s built. Different rhythms of change are another signal.
- Group the answers by how much they matter to Northwind’s success, and you get three tiers:
- Core subdomain — what makes Northwind money or wins customers over a competitor. Invest here, build it well: Order Management, Pricing & Promotions.
- Supporting subdomain — necessary to run the business, but not the differentiator: Inventory, Fulfillment/Shipping.
- Generic subdomain — a solved problem every retailer needs. Buy it or plug in a vendor; don’t spend engineering time reinventing it: Payments Gateway, Authentication.
Each subdomain then typically becomes its own Bounded Context in code — one per subdomain is the common starting point, though a very small subdomain can sometimes share a context with a neighbor.
That mapping shows up directly in how the code gets organized:
src/
├── order-management/ ← Core domain (Bounded Context)
│ ├── Order.ts
│ └── OrderRepository.ts
├── inventory/ ← Supporting domain (Bounded Context)
│ └── Product.ts
├── fulfillment/ ← Supporting domain (Bounded Context)
├── payments/ ← Generic domain — thin wrapper around a payment SDK
└── auth/ ← Generic domain — thin wrapper around Auth0 / Cognito
Notice what this buys you immediately: a new engineer can look at the folder structure and understand the shape of Northwind’s business, not just the shape of the framework.
If Northwind already existed and this is a legacy codebase, not a greenfield build: the method is identical, you just apply it to what’s already there. Read the code for places where the same class name is used two different ways, or where a “simple” change to one part unexpectedly needs sign-off from an unrelated team — those are exactly the seams the four-step method above would have found on day one.
Writing the Boundary Down: A Context Definition Table
The four-step method produces candidates, not final answers. Before a candidate becomes an actual bounded context, it’s worth writing down, in one row per context, exactly what it owns and what its core model looks like — this is the artifact a team actually reviews and argues about, not a diagram:
| Context | Owns | Core model |
|---|---|---|
| Order Management | Orders, checkout, line items | Order (aggregate root), OrderLineItem, RetailPrice |
| Inventory | Stock levels, warehouse SKUs | Product (Inventory’s own), WholesaleCost, StockCount |
| Fulfillment | Shipments, carrier handoff | Shipment, TrackingNumber |
| Payments | Charges, refunds (via vendor) | PaymentAttempt, wrapped behind an ACL |
| Auth | Login, sessions | Account, Session |
This table is a candidate, and candidates get discussed — and sometimes rejected — before they’re standardized. The process that turns a list of candidates into an agreed set of bounded contexts looks like this:
- Propose — draft the table above from the four-step method.
- Discuss — walk it past the people who’d actually own each row. The most common objection is “these two rows aren’t different enough to justify a boundary” — for instance, Northwind initially proposed splitting “Pricing & Promotions” out of Order Management, but discussion revealed the same one team makes every pricing decision as part of placing an order, with no separate rhythm or vocabulary. No real seam existed.
- Reject or merge weak candidates — Pricing & Promotions got folded back into Order Management’s core model instead of becoming its own context. A rejected candidate isn’t a failure of the method; it’s the method working — better to find out in a discussion than after two teams have already built around a boundary that didn’t need to exist.
- Standardize — the surviving rows become the team’s actual context map, documented once, and revisited only when one of the four-step signals changes (a word’s meaning drifts further apart, or ownership genuinely splits into two rhythms).
Fix #1: Ubiquitous Language
Most teams think they share vocabulary. They usually don’t. In meetings, everyone at Northwind nods along when someone says “order” or “product” — in code, those same words quietly drift into something else, exactly as they did in the bug above.
Ubiquitous language is a shared vocabulary that developers and domain experts use everywhere, for one bounded context: meetings, tickets, documentation, and code. There is only one term for a given concept inside that context — you stop translating “business terms” into “engineering terms,” because there’s no translation step left to skip.
Applied to the bug from earlier, the fix starts with a fifteen-minute glossary conversation, not a code review comment:
// After a 15-minute glossary conversation with the business:
// "price" the business pays a supplier is a WholesaleCost.
// "price" the customer pays is a RetailPrice. They are never the same field.
class Product { sku: string; wholesaleCost: Money; retailPrice: Money; stockCount: number; }
function calculateTotal(product: Product, qty: number) {
return product.retailPrice.multiply(qty); // unambiguous — reads like the business talks
}
| Without ubiquitous language | With ubiquitous language |
|---|---|
Business says “cancel an order”; code has deactivateRecord() | Code has order.cancel() — same verb the business uses |
| New engineers reverse-engineer meaning from schema columns | New engineers read domain names and understand behavior directly |
| “Customer” means five different things across five services | “Customer” means one thing, enforced by a bounded context |
What you gain: shared meaning across the team, more readable code, and better decisions — because clear terms tend to expose missing rules, exactly like the wholesale/retail distinction did here. What it costs: upfront workshop time with domain experts, and ongoing discipline — sloppy naming quietly breaks the whole benefit.
Common Confusion: One Language for All of Northwind, or One Per Team?
This is the question almost everyone asks right after learning Ubiquitous Language: is there supposed to be one shared glossary for the whole company?
No. Ubiquitous language is scoped per bounded context, not company-wide. There is no single global glossary sitting above Order Management, Inventory, and Payments. Each context gets its own internally-consistent vocabulary:
- Inside Order Management, “Product” always means a purchasable line item, full stop.
- Inside Inventory, “Product” always means a stocked SKU with a wholesale cost, full stop.
- Neither definition is wrong. Neither team needs to “win.” They just need to never accidentally share the same
Productclass — which is exactly what Bounded Contexts (next section) enforce.
Where two contexts genuinely need to talk — Order Management asking Inventory “is this in stock?” — you don’t force one team’s words onto the other. You translate explicitly at the boundary between them. That translation is called context mapping, and it’s covered in the Deep Dive post. For now, the rule to remember: one language per context, translated at the seams — never one language forced onto everyone.
Fix #2: Bounded Contexts
Renaming one field fixes one bug. It does not stop the next engineer from importing the wrong Product again next quarter, because there is still only one shared Product type serving two genuinely different meanings. Shared language breaks down at scale — not because people are careless, but because Northwind itself is not one single thing anymore; it’s several teams with several jobs.
A bounded context is a clear boundary within which a particular model and language apply. Outside that boundary, the same word can legitimately mean something else. The fix is to give each meaning its own model entirely:
// inventory-context/Product.ts — Inventory's own model, not shared
class Product { sku: string; wholesaleCost: Money; stockCount: number; }
// order-context/Product.ts — Order Management's own model, not shared
class Product { sku: string; retailPrice: Money; } // no wholesaleCost field even exists here
There is no shared “models” folder to accidentally import from anymore — the two Products are entirely different types living in different modules. The confusion isn’t fixed by discipline this time; it’s structurally impossible.
One clarification worth stating plainly: a bounded context is not automatically a microservice. It can be a service, but it can just as easily be a folder/module inside a single monolithic deployment. What makes something a bounded context is the model-and-language boundary, not how it’s deployed.
What you gain: one model no longer becomes a “big ball of mud” trying to mean everything at once; teams evolve their own contexts independently; local refactors stay local. What it costs: you now have to deliberately design how contexts talk to each other when they do need to interact (covered in the deep-dive post); getting a boundary wrong creates duplicated logic.
Building Blocks Inside a Context: Entities vs. Value Objects
Once you’re inside a single bounded context, you still need to model the domain correctly rather than just moving the mess one level down. Two kinds of building blocks do most of the work:
- Entity — defined by identity and lifecycle. A
Customerstays the same person even after their address changes. Entities are mutable. - Value Object — defined only by its values, and usually immutable.
Moneyhas no identity of its own — twoMoneyobjects holding the same amount and currency are the same value. Value objects are immutable.
class Customer { // Entity — has an ID that outlives any single attribute
constructor(public readonly id: string, private address: Address) {}
relocate(newAddress: Address) { this.address = newAddress; } // same customer, new address
}
class Money { // Value Object — immutable, compared by value, no ID
constructor(public readonly amount: number, public readonly currency: string) {}
multiply(qty: number) { return new Money(this.amount * qty, this.currency); }
}
What you gain: clearer intent in the code (identity and attributes stop being mixed together), fewer accidental bugs from shared mutable state, and domain behavior you can test directly. What it costs: more types to write upfront, and the discipline not to slip into “anemic models” — plain data bags with no behavior — which throws away most of the benefit.
When to Reach for DDD (and When Not To)
DDD is a tool, not a default. Reach for it when:
- The domain is complex and keeps evolving — think finance, healthcare, logistics, or large marketplaces.
- Multiple teams work on overlapping parts of the same system.
- The system needs to live and change safely for years, not months.
Skip it when:
- The app is mostly CRUD — DDD adds layers and coordination overhead you don’t need.
- You can’t get sustained access to domain experts — DDD becomes guesswork, and guesswork hardens into code faster than you’d expect.
- The project is short-lived or low-impact — the modeling investment won’t pay itself back.
You don’t adopt DDD by sprinkling in patterns for their own sake. You adopt it by removing ambiguity — starting with the words your team actually uses.
What’s Next
Northwind isn’t finished. In the deep-dive post, the same Order Management system runs into an inconsistent-total bug that Aggregates fix, a tightly-coupled service call that Domain Events fix, SQL leaking straight into business logic that Repositories fix (and, right alongside it, the “one database or many?” question answered directly), and a legacy payment gateway’s field names leaking into the domain that the Anti-Corruption Layer fixes.
Sources
- ByteByteGo — Domain-Driven Design (DDD) Demystified
- Nikki Siapno, Level Up Coding — Domain-Driven Design, Broken Down
- Companion deck: Intro — DDD for Beginners