Guide · Backend
Domain-Driven Design and Bounded Contexts, Explained Simply
27 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Domain-driven design (DDD) builds software around a model of how the business works, written in the words its experts use. A model is a simplified picture of the business in code.
- Its most useful idea is the bounded context: one part of the system, usually owned by one team, inside which each word, like order or delivered, has exactly one meaning.
- The same word often means different things in different parts of a company. DDD lets each part keep its own meaning and translates at the edges between them.
- Good service boundaries usually follow bounded contexts, not nouns like Product or Order. Prove a boundary first as a module, a separate folder with its own code and tables, inside one app.
- Beginners can stop after the section on mistakes. Engineers then get two tested checks. A version number stops two saves at once from putting 31 stops on a 30-stop van, and an import rule fails the build when one module reaches into another.
What goes wrong when service boundaries are drawn around nouns?
One team's change quietly changes the meaning of a shared word for everyone else, and bugs appear in parts of the system nobody touched. Domain-driven design is the approach built to stop this.
Picture a common setup. A farm-to-home produce marketplace in Pune buys vegetables and fruit from about 1,800 farmers, stores them in two warehouses and delivers around 9,000 home orders a day. The setup is made up, but the problem is real.
A year ago, the architect split the old single app into services. The split followed nouns: a Product service, an Order service and a User service. It looked neat on the whiteboard.
The procurement team buys produce from farmers and records each purchase from a farmer as an order. To the delivery team, an order is a bag going to a customer's home.
Both kinds of order lived in the same Order service and the same orders table. One Monday, procurement shipped a small feature. It marked a farmer's purchase as delivered when the truck reached the warehouse.
A notification job read every row with the status delivered. By 7 am it had texted 2,300 customers that their order had arrived. Their vegetables were still in crates at the warehouse.
Follow the arrows in the diagram below into the single Order service. Three teams with three meanings of order all write to one place.
The marketplace split by noun
Drag to exploreThree teams write three meanings of order into one shared table. Notice that the notification job cannot tell which meaning a delivered row has.
Diagram connections:
- Procurement app to Order service
- Customer shop app to Order service
- Rider app to Order service
- Order service to Shared orders table
- Shared orders table to Notification job (Reads status = delivered)
- Notification job to Customer phones
- Procurement app: Procurement logs purchases from farmers
- Customer shop app: Customers place home orders
- Rider app: Riders mark bags handed over
- Order service: One Order class for every team
- Shared orders table: One status column, three meanings
- Notification job: Texts customers on every delivered row
- Customer phones: 2,300 wrong delivered texts
Nobody wrote bad code here. Each team used the word order correctly for its own work. The design gave them no way to keep their meanings apart.
Which words do you need before learning domain-driven design?
Four words come up in every section. Here is each one, shown on the marketplace.
- Domain: the business area your software serves. Here it is buying produce from farmers and getting it to homes while it is still fresh.
- Subdomain: one part of that business with its own experts and goals, such as procurement, warehousing, the customer shop, delivery or payments.
- Model: a simplified picture of the business in code that keeps only the details one job needs. The delivery model cares about addresses and time slots, not which village the tomatoes came from.
- Domain expert: a person who knows how the work is really done, such as the procurement lead who sets grades or the delivery lead who plans routes.
What is domain-driven design?
Domain-driven design is an approach where you build software around a model of the business, written in the same words the business experts use. The name comes from a 2003 book by Eric Evans, which describes the approach as a catalogue of patterns.
It suits businesses with many rules, like produce that spoils in two days and farmers paid by quality grade. For a simple form that saves data to a table, it adds work and gives little back.
DDD work has two halves. Strategic design is the big picture: where to draw boundaries and how the parts relate. Tactical design is the code inside one boundary, with building blocks such as aggregates, covered later.
Martin Fowler calls strategic design a particularly important part of DDD. Start there, because a well-built aggregate rarely rescues a boundary drawn in the wrong place.
Core, supporting and generic subdomains
DDD also asks you to rank your subdomains, so your best people work where it matters. Evans's reference names the core domain and generic subdomains. Microsoft's architecture guide also uses a middle kind, called supporting.
- Core subdomain: the part that gives you an edge over competitors. For the marketplace, it is procurement and quality grading, because fresh, well-graded produce is why customers come back.
- Supporting subdomain: needed and specific to you, but not where you win. The warehouse's crate and shelf planning fits here.
- Generic subdomain: a problem many businesses have already solved. Payments and text messages are generic, so the marketplace uses a payment gateway and an SMS provider.
What is a bounded context in DDD?
A bounded context is a boundary, usually one part of the system or the work of one team, inside which one model and its words have one exact meaning. Outside it, the same word may mean something else.
Think of the land unit bigha. A bigha in West Bengal or Assam is about 1,340 square metres. A pucca bigha in Rajasthan is about 2,530 square metres, nearly twice as much.
Nobody fixes this by forcing one bigha on the whole country. People say which state's bigha they mean, and convert when a land deal crosses a state line.
- The state is the bounded context: the place where one meaning holds.
- The bigha is a word like order or delivered: the same name, a different amount in each place.
- Converting at the state line is the translation code at a context's edge.
- Square metres are the shared format that both sides agree to use when they meet.
Where the picture breaks: a bigha conversion is one multiplication. Translating procurement's lot into the warehouse's stock record needs rules, lookups and rejections, as the translation code later in this guide shows.
This is not only a beginner's puzzle. Martin Fowler describes an electricity utility where meter meant the physical device to some teams. To others it meant a connection between the grid and a location, or the grid and a customer.
Fowler also quotes Evans directly on why one model for everything fails: "total unification of the domain model for a large system will not be feasible or cost-effective."
So each context gets its own model, and you translate between them. Here is how four common words split across the marketplace's contexts. Read each row across and notice that no meaning is wrong.
| Word | Procurement context | Warehouse context | Delivery context |
|---|---|---|---|
| Order | A purchase from one farmer, priced per kg by grade | Not used; it talks about inbound lots and picks | One customer's bag for one time slot |
| Delivered | The farmer's truck reached the warehouse gate | Crates put away on a shelf | Bag handed to the customer at the door |
| Product | A crop and grade, like Grade A tomato | A stock item counted in crates, with a best-before date | A line on the bag list, with a weight |
| Quantity | Accepted kilograms after grading | Number of crates | Number of packs in the bag |
What is ubiquitous language and why does it matter?
Ubiquitous language is the shared, precise set of words that developers and domain experts both use, in meetings and in the code. Ubiquitous just means present everywhere. It matters because software copes badly with vague words, while people cope fine.
Inside the procurement context, the team agreed on a few exact terms. A lot is one farmer's batch of one crop on one day. Grading sorts a lot into Grade A, B or C. Accepted kg is the weight left after grading.
Those words then appear in the code as class and function names: Lot, grade_lot, accepted_kg. When the procurement lead reads a bug report, it uses their own words. When a developer hears lot, they know which class it means.
Evans's reference defines the language as used by the team within one bounded context, not across the company. Delivery has its own words: run, stop, slot and handover. Forcing one company-wide dictionary is how a Product class grows dozens of optional fields nobody fully understands.
How do you find bounded contexts in a real business?
You find them by walking through the business with its experts and marking where the words, the people and the rules change. Code structure comes last, after you understand the work.
One structured way to do this is event storming, a workshop format created by Alberto Brandolini. Experts and developers write business events on sticky notes, orange by convention, and lay them out in time order along a wall.
The marketplace's architect ran a half-day session with the procurement, warehouse, shop and delivery leads. These steps are a simplified version of what they did.
Write each event as something that already happened
Things like lot graded, crates shelved, order placed, bag packed and bag handed over. Naming what already happened keeps people talking about facts, not screens.
Put them in time order
Lay the notes from the farmer's field on the left to the customer's door on the right. Gaps and arguments show up quickly.
Mark where words change meaning
Circle every word that two groups use differently, like delivered. A change in meaning is the strongest sign of a boundary.
Note who decides each step
Write the expert and team next to each group of events. Procurement decides grades, and delivery decides routes.
Draw boundaries and name them
Group events that share words and owners into contexts with clear names. The marketplace ended up with procurement, warehouse, shop and delivery.
Test the boundaries with real features
Take three recent features and ask how many contexts each touched. A good boundary keeps most features inside one context.
Beyond language, three other signs point to a boundary:
- Different experts. If two groups go to different people for answers, they are likely different contexts.
- Different speed of change. Grading rules change every season, while address formats almost never change.
- Different data ownership. Only procurement should decide what a farmer is paid, so only procurement writes that number.
Is a bounded context the same as a microservice?
No. A bounded context is a boundary of meaning, while a microservice is a separately deployed program. One context can be one service, several services, or a module inside one app.
The firmer rule runs the other way. Microsoft's microservices guide says a service's functionality generally shouldn't span more than one bounded context. If one service holds both procurement's order and delivery's order, the meanings collide again, just as they did that Monday.
A context may still be split into a few services for technical reasons. Delivery's route planner needs a lot of computing power at 5 am, so it runs as its own service. It still speaks delivery's language.
Before creating any new service, the architect now asks three questions in order. Start at the top box and follow the answers.
Should this become its own service?
Drag to exploreLanguage and ownership come first. Deploying separately is the last question, not the first.
Diagram connections:
- Do its words differ? to Keep it in its context (No)
- Do its words differ? to Does one team own it? (Yes)
- Does one team own it? to Settle ownership first (No)
- Does one team own it? to Needs its own deploys? (Yes)
- Needs its own deploys? to New module, same app (No)
- Needs its own deploys? to New service (Yes)
- Do its words differ?: Does delivered or order mean something else here?
- Does one team own it?: One expert and one team decide its rules
- Needs its own deploys?: Different release pace, scale or uptime needs
- Keep it in its context: Part of an existing module or service
- New module, same app: Own folder, own tables, deployed together
- New service: Own code, own database, own deploys
- Settle ownership first: Agree which team owns these rules
How do bounded contexts talk to each other?
Bounded contexts talk through small, agreed interfaces, such as an API (a set of calls one program offers others) or published events. Each side translates the other's words into its own. A context map is the drawing that shows these connections and who depends on whom.
Two more words help. Evans's reference calls a team upstream when its actions affect the other team's success, and downstream when it is the one affected. Procurement is upstream of the warehouse, because the warehouse can only shelve what procurement accepts.
Go back to the bigha. The two sides of a land deal agree on one unit, such as square metres, before they sign. Contexts do the same with a shared format at their border, which the reference calls a published language.
Evans's DDD Reference names nine relationships between contexts. The table shows the seven this example uses. The other two are partnership, where two teams succeed or fail together, and big ball of mud, a messy legacy area fenced off as it is.
| Relationship | What it means | Marketplace example |
|---|---|---|
| Customer/supplier | The downstream team's needs go into the upstream team's planning | The warehouse asks procurement to add a harvest date to every lot event |
| Conformist | The downstream side adopts the upstream model as it is, with no translation | The shop uses the payment gateway's own payment model and status names |
| Anticorruption layer (ACL) | The downstream side builds a translation layer so the other model cannot leak in | The warehouse translates procurement lots into its own stock items |
| Open host service | One context offers a documented protocol that any other context can call | The warehouse offers one stock API that both the shop and delivery call |
| Published language | A well-documented shared format that contexts translate to and from | Lot events use one documented format that every reader of lot events understands |
| Shared kernel | Two teams share a small, agreed piece of model and code, changed only together | The shop and delivery share one time-slot definition |
| Separate ways | No connection at all, because joining them costs more than it saves | The farmer training videos site runs on its own |
Here is the marketplace's context map after the workshop. Each box is one context or bought service, and each arrow points from the upstream side to the side that depends on it.
The marketplace's context map
Drag to exploreEach context owns its own data and words. Notice that every arrow names how two contexts connect, and no two contexts share a table.
Diagram connections:
- Procurement context to Warehouse context (Lot events, warehouse ACL)
- Warehouse context to Shop context (Stock API (open host))
- Warehouse context to Delivery context (Stock API (open host))
- Shop context to Delivery context (Order placed events)
- Payment gateway to Shop context (Shop conforms)
- SMS provider to Delivery context (Handover texts via its API)
- Procurement context: Core: farmers, lots, grades, farmer pay
- Warehouse context: Supporting: crates, shelves, best-before dates
- Shop context: Catalogue, prices, customer orders
- Delivery context: Runs, stops, slots, handovers
- Payment gateway: Generic: bought, not built
- SMS provider: Generic: sends customer texts
An anticorruption layer in Python
The warehouse does not want procurement's words inside its code. So it keeps one small translator at its border, the anticorruption layer from the table. The function below takes a lot event from procurement and returns the warehouse's own StockArrival record.
from dataclasses import dataclass
KG_PER_CRATE = {"tomato": 20, "onion": 25, "spinach": 5}
SHELF_DAYS = {"tomato": 5, "onion": 30, "spinach": 2}
@dataclass(frozen=True)
class StockArrival: # the warehouse's own words
sku: str
crates: int
best_before_days: int
def from_lot_event(lot: dict) -> StockArrival:
crop = lot["crop"].strip().lower()
if lot["grade"] not in ("A", "B"):
raise ValueError("Only Grade A and B lots are shelved")
crates = -(-lot["accepted_kg"] // KG_PER_CRATE[crop]) # round up
return StockArrival(sku=f"{crop}-{lot['grade']}",
crates=int(crates),
best_before_days=SHELF_DAYS[crop])
print(from_lot_event({"crop": " Tomato ", "grade": "A", "accepted_kg": 412.5}))Run it with Python 3.13 and it prints one line:
StockArrival(sku='tomato-A', crates=21, best_before_days=5)The tomato lot had 412.5 kg accepted. At 20 kg per crate that is 20.6 crates, rounded up to 21 so no produce goes uncounted. The crate sizes and shelf days are made up.
Three lines matter. StockArrival is the warehouse's model, counted in crates, not kilograms, with an sku (stock-keeping unit) code per crop and grade. The grade check keeps Grade C lots out of stock. The crates line rounds up to whole crates.
If procurement adds a Grade A+, the check rejects it until someone changes this one function. A crop the warehouse has never stocked, such as okra, fails with a KeyError, Python's missing-key error. Both fail loudly at the border, and the fix touches only this function.
Events between contexts
Many contexts connect through domain events: records that something the experts care about already happened, like lot accepted. A message queue (a service that stores messages until the receiver reads them) carries them. Follow one lot from the warehouse gate to a customer's screen below.
One lot crossing three contexts
Drag to exploreEach context reacts to the event in its own words. Notice that nobody writes into another context's tables.
Diagram connections:
- Lot graded at the gate to Publish lot accepted
- Publish lot accepted to Translate lot to stock
- Translate lot to stock to Record crates on shelves
- Record crates on shelves to Show item as in stock
- Show item as in stock to Customer adds to bag
- Lot graded at the gate: Gate staff → Procurement context
- Publish lot accepted: Procurement → Message queue
- Translate lot to stock: Message queue → Warehouse ACL
- Record crates on shelves: Warehouse ACL → Warehouse database
- Show item as in stock: Warehouse stock API → Shop context
- Customer adds to bag: Customer app → Shop context
Notice what fixed the Monday problem. A lot reaching the warehouse is now an event called lot accepted. The word delivered exists only in the delivery context, where it always means a bag handed over at a door.
What is an aggregate in DDD and how big should it be?
An aggregate is a small cluster of objects that are loaded, changed and saved together as one unit, through one entry object called the aggregate root. Keep it as small as your business rules allow.
An aggregate protects a rule that must always be true, called an invariant. In delivery, one van run must never have more than 30 stops or more than 400 kg of bags (made-up limits). So a run and its stops form one aggregate, with the run as its root.
Fowler's summary gives two rules. Outside code holds references only to the root, never to a stop directly. And transactions, groups of database changes that succeed or fail together, should not cross aggregate boundaries.
Vaughn Vernon's 2011 essays on aggregate design add more. Change only one aggregate per transaction. Refer to other aggregates by their id. Update other aggregates through events, a moment later.
class DeliveryRun:
"""Aggregate root: one van, one slot, and its stops."""
MAX_STOPS = 30
VAN_CAPACITY_KG = 400
def __init__(self, run_id: str, slot: str):
self.run_id = run_id
self.slot = slot
self._stops = [] # only the root should touch this list
def add_stop(self, order_id: str, address: str, weight_kg: float):
load = sum(s["weight_kg"] for s in self._stops) + weight_kg
if len(self._stops) >= self.MAX_STOPS or load > self.VAN_CAPACITY_KG:
raise ValueError("Run is full: start a new run")
self._stops.append({"order_id": order_id, "address": address,
"weight_kg": weight_kg})
run = DeliveryRun("R-0612", "07:00-09:00")
for n in range(30):
run.add_stop(f"O-{n}", "Kothrud", 8.0)
try:
run.add_stop("O-30", "Baner", 8.0)
except ValueError as err:
print(err)
run._stops.append({"order_id": "O-31", "address": "Aundh", "weight_kg": 8.0})
print(len(run._stops), "stops")It prints:
Run is full: start a new run
31 stopsHere is what happened. The 31st add_stop call was refused, as planned. Then one line reached past the root, appended to _stops directly, and the van now has 31 stops.
The underscore on _stops is a Python naming habit that means do not touch this from outside. Python does not enforce it. The root protects the rule only while every caller goes through add_stop, so code review has to keep it that way.
The run also stores order_id, not the whole customer order, because the order belongs to the shop context. Vernon calls this referring to other aggregates by identity. A price change in the shop then never has to load or save a delivery run.
What mistakes make DDD service boundaries fail?
Most failed boundaries come from drawing them around data or technology instead of business meaning, or from drawing them once and never checking again. The architect now looks for these six in every design review.
- Splitting by noun. A Product service or an Order service ends up serving every team's meaning at once, and every feature touches it.
- Sharing one database. Separate services that write the same tables still share one model, now with extra network calls.
- Making services too small. If placing one order needs six services to agree, the boundary is inside a context, not around it.
- Building one company-wide model. A Product class that tries to please every team ends up with dozens of optional fields.
- Skipping the experts. Boundaries guessed from the code copy the code's old mistakes. Talk to the people who do the work first.
- Never redrawing. Businesses change. When most new features start crossing a boundary, it is time to move it.
One simple health check helps: count how many contexts each feature changes. Before the workshop, 7 of the last 10 features touched three or more services. Six months later, 8 of the next 10 stayed inside one context (both counts made up). The engineer section shows a git command that counts this for you.
Also be honest about cost. DDD takes time with experts, careful naming and translation code. For a small app with one team and simple rules, a clean single codebase is usually the better choice.
How do you stop two saves from breaking an aggregate's rule?
Give the aggregate a version number, and make every save say which version it read. If someone else saved first, the save changes nothing, and the request reloads and tries again. This is called optimistic concurrency.
Vernon's first aggregate essay uses exactly this to protect invariants from overlapping changes. The add_stop check alone is not enough. Two requests can each load the same run with 29 stops, each see room for one more, and each save.
Pause and predict: how many stops does the van end up with? The script below plays it out, first without the version check and then with it. It uses SQLite from Python's standard library, so it needs no setup.
import sqlite3
def fresh_db():
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE runs (run_id TEXT PRIMARY KEY, version INTEGER)")
db.execute("CREATE TABLE stops (run_id TEXT, order_id TEXT)")
db.execute("CREATE TABLE outbox (event TEXT)")
db.execute("INSERT INTO runs VALUES ('R-0612', 7)")
db.executemany("INSERT INTO stops VALUES ('R-0612', ?)",
[(f"O-{n}",) for n in range(29)])
db.commit()
return db
def load(db, run_id):
version = db.execute("SELECT version FROM runs WHERE run_id = ?",
(run_id,)).fetchone()[0]
count = db.execute("SELECT COUNT(*) FROM stops WHERE run_id = ?",
(run_id,)).fetchone()[0]
return version, count
def add_stop(db, run_id, order_id, seen_version, seen_count, check_version):
if seen_count >= 30:
raise ValueError("Run is full: start a new run")
sql = "UPDATE runs SET version = version + 1 WHERE run_id = ?"
args = [run_id]
if check_version:
sql += " AND version = ?"
args.append(seen_version)
with db: # one transaction: all three changes commit, or none do
if db.execute(sql, args).rowcount == 0:
raise RuntimeError("Run changed since you loaded it: reload and retry")
db.execute("INSERT INTO stops VALUES (?, ?)", (run_id, order_id))
db.execute("INSERT INTO outbox VALUES (?)", (f"stop added to {run_id}",))
for check in (False, True):
db = fresh_db()
a = load(db, "R-0612") # two requests read the same run:
b = load(db, "R-0612") # version 7, 29 stops
add_stop(db, "R-0612", "O-A", *a, check)
try:
add_stop(db, "R-0612", "O-B", *b, check)
except RuntimeError as err:
print(err)
try:
add_stop(db, "R-0612", "O-B", *load(db, "R-0612"), check)
except ValueError as full:
print("Retry:", full)
events = db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]
print(f"check_version={check}: stops={load(db, 'R-0612')[1]}, outbox={events}")On Python 3.13.16 with SQLite 3.45.1 it prints:
check_version=False: stops=31, outbox=2
Run changed since you loaded it: reload and retry
Retry: Run is full: start a new run
check_version=True: stops=30, outbox=1Here is what happened. Without the check, both saves went through and the van has 31 stops, even though each request checked the limit. With the check, the second save matched no row, so it raised an error instead.
Its retry reloaded the run, saw 30 stops and was refused properly. The outbox count shows 1, not 2, because the failed save stopped at its version check before writing a stop or an event. If anything inside the with block fails, the connection rolls back every write in it, so a stop is not saved without its event.
Does this hold with two real connections?
The script runs both requests one after the other on one connection. To check real concurrency, we ran the same two updates from two separate sessions on PostgreSQL 16.15. We used its default isolation level, read committed, where each statement sees only data other transactions have already committed. The first session held its transaction open for 2 seconds.
The second session's UPDATE waited until the first committed, about 1.5 seconds. With the version check it then reported UPDATE 0, so the app would raise and retry. Without the check it reported UPDATE 1, so the second stop would go in.
PostgreSQL's documentation explains why. In read committed mode, an update that waited for another transaction re-checks its WHERE clause against the newly committed row. The row now says version 8, so version = 7 no longer matches.
At the stricter repeatable read level, the second update failed instead, with the error could not serialize access due to concurrent update. Either way, the app must reload and retry.
Why the outbox table matters
Saving the run and sending a stop added event touch two different systems. If the app commits and then crashes before publishing, the event is lost. The transactional outbox pattern avoids this by writing the event into an outbox table inside the same transaction.
A separate process then reads the outbox and sends each event to the message queue. That relay can send a message more than once, for example after a crash between sending and marking it sent. So every consumer must be idempotent: handling the same event twice must have the same effect as once.
How do you check that module boundaries hold in one codebase?
Write the boundaries down as rules a tool checks on every build. For Python, import-linter fails the check when one package imports another it should not. We tested version 2.15, the latest release as of 19 October 2026.
Lay out the code with one package per context under a market package, plus a published package for the shared event formats. The contexts may import published, but never each other. This config says so:
[importlinter]
root_package = market
[importlinter:contract:contexts]
name = Bounded contexts do not import each other
type = independence
modules =
market.procurement
market.warehouse
market.shop
market.deliverySave it as a file named .importlinter in the project folder and run lint-imports. On a clean project it prints Contracts: 1 kept, 0 broken, and exits with code 0.
Now make the warehouse import procurement's Lot class directly, and run it again. The relevant lines of the output:
Bounded contexts do not import each other BROKEN
Contracts: 0 kept, 1 broken.
market.warehouse is not allowed to import market.procurement:
- market.warehouse.acl -> market.procurement.grading (l.1)The exit code is now 1, so a CI pipeline, the automated checks that run on every push, fails the build. In our test it also caught an indirect path: warehouse to a helper in published, then on to procurement.
One gap: the tool reads import statements. A call to importlib.import_module that loads procurement's code at run time passed the check in our test on 2.15. Code review still has to catch that.
How many contexts does each change touch?
Count how many context folders each commit touches. A commit is only a rough stand-in for a feature, but the trend shows whether the boundaries fit the work. This command assumes each context lives in its own folder under market/. Files directly in market/, such as __init__.py, are not counted.
git log -n 10 --format='@%h' --name-only | awk -F/ '
/^@/ { if (id) print id, n; id = substr($0, 2); n = 0; split("", seen); next }
$1 == "market" && NF > 2 && !($2 in seen) { seen[$2] = 1; n++ }
END { if (id) print id, n }'Each output line is a short commit id and the number of contexts that commit touched. In a scratch repository with four test commits, it printed this (your commit ids will differ):
fc25f40 4
cc1a9b2 2
3f29bf7 1
033fc2b 1The newest commit, a rename of delivered across the codebase, touched all 4 contexts. One such commit is normal. If most recent commits touch three or more contexts, the features do not fit your boundaries, and it is time to redraw them.
How do you explain domain-driven design in a system design interview?
Explain it as a way to find service boundaries from the business itself, then show one boundary, one translation and one aggregate on a concrete example.
Name the subdomains
List the parts of the business out loud, and say which one is core. For a produce marketplace: procurement, warehouse, shop, delivery and payments.
Show a word that splits
Pick one word with two meanings, like delivered. This proves you understand why one shared model breaks.
Draw the context map
Draw each context as a box with its own data. Label each arrow with how they connect: API, events or an anticorruption layer.
Map contexts to services
Say which contexts become services and why. Mention that smaller teams might start with modules in one app.
Show one aggregate
Pick one invariant, like the van limit, and show the aggregate that protects it. Add the version check that stops two saves from breaking it.
Handle work across contexts
Explain that a process spanning contexts, such as refunds, uses events, retries and compensating steps instead of one big transaction. A compensating step is an action that undoes an earlier one, such as releasing stock that a cancelled order had reserved.
Questions people ask
- What is the difference between a domain and a bounded context?
- A domain or subdomain is a part of the business, while a bounded context is a boundary in your software where one model applies. They often line up one to one, but not always. One subdomain can need two contexts, and an old system can cover several subdomains in one context.
- Do I need microservices to use domain-driven design?
- No. DDD works well inside a single app, where each bounded context becomes a module with its own folder and tables. You can start this way. An import rule, like the import-linter contract shown above, keeps the modules apart. Splitting a proven context into a separate service later is much easier than fixing a wrong service boundary.
- What is an anticorruption layer?
- An anticorruption layer is translation code that sits at the edge of your context and converts another system's model into yours. It stops another team's words and quirks from leaking into your code. It is most useful when the other system is old or messy, or when its team cannot change for you.
- How many bounded contexts should a system have?
- There is no right number, because it depends on how many distinct parts the business has. The marketplace in this guide ended up with four of its own, plus two bought services. If you have more contexts than teams, or features keep crossing them, your contexts may be too small.
- Is DDD only for large companies?
- No, but its full weight pays off mainly in complex businesses. A small team can still use the cheap parts: speak the experts' words in code, and keep different meanings in separate modules. Skip the heavier parts until the rules get complicated enough to need them.
- How do bounded contexts keep data consistent with each other?
- They usually accept a short delay, called eventual consistency. One context finishes its own change and writes an event to an outbox in the same transaction. A relay publishes it, and the others update when they read it. For a process across several contexts, a saga (a chain of steps with undo steps for failures) replaces one shared transaction.
Sources and further reading
- Domain-Driven Design Reference: Definitions and Pattern Summaries, March 2015 PDF (Eric Evans, Domain Language)
- Bounded Context (Martin Fowler, 15 January 2014): the meter example and the Evans quote
- Domain Driven Design (Martin Fowler, 22 April 2020): the 2003 book and strategic design
- Ubiquitous Language (Martin Fowler, 31 October 2006)
- DDD Aggregate (Martin Fowler, 23 April 2013): references to the root, transactions
- Effective Aggregate Design, Part I (Vaughn Vernon, 2011): small aggregates, optimistic concurrency
- Effective Aggregate Design, Part II (Vaughn Vernon, 2011): reference by identity, eventual consistency
- Architecture guide: Using domain analysis to model microservices (core, supporting and generic subdomains)
- Architecture guide: Use tactical DDD to design microservices
- Architecture guide: Identify microservice boundaries (one service, one bounded context)
- Event Storming (The Open Group, Open Agile Architecture standard)
- How Do Committees Invent? (Melvin E. Conway, Datamation, April 1968)
- PostgreSQL 16 documentation: Transaction Isolation, Read Committed
- Pattern: Transactional outbox (Chris Richardson, microservices.io)
- import-linter documentation: contract types
- Bigha (Wikipedia): regional sizes of the land unit