Bounded contexts, concretely
See how bounded contexts keep different meanings separate, translate external API facts at the edge, and avoid a shared model that spreads confusion.
A bounded context is where one domain model and vocabulary apply. Different meanings of customer or order are useful clues; different lifecycles and rules strengthen the case. Contexts exchange explicit contracts such as events or APIs, carrying the identifiers and facts they need without sharing an internal model.
Sales and dispatch keep customer records like two address books for different jobs. Sales knows who may buy; dispatch knows where an order goes. They share a small agreed message when work passes between them, while each keeps its own record and rules.
A bounded context gives one domain model and vocabulary a clear scope. To find a useful boundary, start with words that mean different things in different workflows, then check whether their lifecycles, business rules and change pressures also diverge. The boundary keeps internal models separate; it does not forbid data crossing through an explicit API or event contract.
Find the boundary by finding the word#
Run the test on customer in this illustrative sales-and-dispatch example. The two parts of this system do not define it the same way.
A customer in sales has a pipeline stage, a lead source, and a rep who owns the relationship. It has no address anyone would post a letter to, because at that stage nobody has one. A customer in dispatch has a delivery name that must match the signed receipt, a postal address, access notes and a service level; it has no pipeline stage, because by the time you are invoicing someone the pipeline is over. A customer in support has a contract tier, a ticket history and an escalation path, and cares about none of the above.
Three sets of attributes, three lifecycles, three teams each of whom will tell you theirs is the real customer. All three are right. The word is doing three jobs, and the jobs are genuinely different.
The word is a first clue, not a complete boundary test. Check three more signals. Lifecycle: a sales prospect can be lost without ever becoming a delivery recipient, while a dispatch recipient may persist through returns; split when the same record would need incompatible state transitions. Rules and consistency: dispatch must validate a delivery address before scheduling, while sales may work without one; split when one model would make a rule optional for most callers. Change cadence: sales may change lead scoring frequently while dispatch’s proof-of-delivery contract changes rarely; split when shared releases repeatedly force unrelated teams to coordinate. These signals matter most when a noun alone is ambiguous.
The argument is how short each one is. Neither class is missing anything: each holds exactly what its own side of the system needs to do its work, and nothing it would have to leave empty.
You can see the symptom when a codebase refuses this. One class tries to be all three, and it grows fields that most callers ignore — a table with nullable columns nobody can explain, a constructor with eight optional arguments, a validation rule that is only correct when called from one place. The comment # only set for dispatch customers is the boundary, written down as an apology instead of as a decision.
Two things are worth being precise about. The first is that the boundary goes where the meaning changes, not where the org chart happens to split. Those often coincide, and when they do not, the meaning wins — a team that owns two meanings owns two contexts, and pretending otherwise just moves the confusion inside the team. The second is what the boundary buys you, which is smaller than people expect and more useful: inside it, one word means one thing, so a rule about customers can be written without a qualifier. That is the whole benefit. Everything else follows from being able to say something true in one sentence.
This is what the pattern language calls a ubiquitous language, and the part most often misquoted is its scope. The shared vocabulary lives inside one boundary, not across the system. There is no goal of getting the whole company to agree on what a customer is. The goal is that within each context, everyone — including the code — uses the same word the same way.
What crosses, and what stays#
The internal model does not cross as a shared object. A contract can carry an identifier and the facts the recipient needs, in terms both sides have agreed to exchange. For example, Sales can publish a SaleWon event containing a customer id and order id; Dispatch then collects and validates delivery details in its own model. The event is an integration contract, not the Sales Customer class serialized wholesale.
| From → to | Contract at the seam | Recipient’s interpretation |
|---|---|---|
| Sales → Dispatch | SaleWon: customer id, order id | Start a delivery job; gather and validate address and access details. |
| Dispatch → Support | DeliveryCompleted: order id, delivered time | Attach fulfillment evidence to a support case when needed. |
This is a context map for the example, not a proposed universal event schema. Sales owns prospect state, Dispatch owns delivery state, and Support owns case state. Contract fields are chosen for the receiving task; none of those contexts imports another’s complete Customer model.
Somewhere a piece of code turns one contract into the receiving model. Make that translation and its owner explicit. An anti-corruption layer is a translation seam at the edge of a context whose job is to stop the other side’s model leaking into yours. Without that seam, a sales stage field can appear in a delivery note, then in a report, then in a query that nobody can change because three teams read it.
Between any two contexts there is a relationship, whether or not anyone named it. Naming it is useful because each one commits you to something different.
| Relationship | What it means in practice | What it costs |
|---|---|---|
| Shared kernel | Both sides share a deliberately small piece of model and own it jointly. | Neither side can change the shared part alone. Works when the two teams talk daily; fails quietly when they stop. |
| Customer–supplier | One context depends on another, and can put its needs on the other’s backlog. | A real commitment by the upstream team. Without it the label is aspirational and you are actually a conformist. |
| Conformist | You take the upstream model exactly as it is and do not translate. | Cheapest to build, and upstream’s model is now yours — including the parts that make no sense on your side. |
| Anti-corruption layer | You translate at your edge and keep your own model clean. | Code to write and keep working. It is the price of not letting a model you do not control set your vocabulary. |
These are relationships between two contexts, not qualities of one. The same context is usually a conformist to one neighbour and runs a translation layer against another, and that is normal rather than inconsistent.
The next published stop on this path takes one such boundary — a write API and the systems that retry against it — and works through what the contract across it has to promise: idempotency keys and safe retries. The architecture path holds the rest in the order that makes each step explain the next.
External APIs bring their own language#
A boundary does not require a second team inside your company. An API you do not control arrives with its own nouns, states and lifecycle, and those words are true inside the provider’s system rather than automatically true inside yours. In a simplified example, a messaging API reports message.created. That means the provider recorded a message; it does not establish that your domain’s idea of a meaningful interaction happened. The message might be automated, retried, inbound, or a notification your product does not count at all.
Translate that fact at the edge before core code sees it. Keep the provider identifier and enough correlation data at the integration boundary to explain which external fact produced which internal one, then emit the smallest statement your own model can defend. If the external signal is insufficient, the honest internal state is unknown or needs confirmation, not a stronger business fact invented from the provider’s vocabulary. A provider status should not become a field on every domain object merely because the webhook arrived first.
That is the anti-corruption-layer row above with an external system on the other side. If the provider renames a state, changes a payload or is replaced, the translation code changes while the rest of the model keeps talking in its own terms. When the two meanings genuinely match, conforming can still be cheaper; translation is valuable because the semantics differ, not because an adapter is fashionable. And once the translation has produced an internal event, its delivery guarantees are a separate question — the distinction worked through in events and what they promise.
The shared core library#
It arrives with impeccable reasoning: two contexts both have a Customer, somebody notices the duplication, and the common parts are extracted into a package both sides depend on. Nobody objects, because nobody argues in favour of duplication.
Then the two models start to move apart, because they were never the same model.
A one-line change needs three releases
Dispatch needs a field. The field goes in the shared package, the package is released, and every other context upgrades whether it wanted the field or not. A local change became a coordinated one.
The shared class fills with optional fields
Each one means not applicable here, and which here it refers to is not written anywhere. Every consumer carries a null check for a case that cannot occur on its side.
Nobody can say no
The package belongs to everyone, which means it belongs to nobody. A change that is wrong for two contexts and right for one lands anyway, because there is no owner positioned to refuse it.
The uncomfortable conclusion, and the one that gets argued about: duplicating the class was the cheaper option. The two things were never the same thing, so what looked like duplication was two similar-looking definitions of different concepts — and merging those does not remove work, it converts private work into negotiated work. The fields drift apart on their own, and the shared package spends its life resisting.
That does not retire the shared kernel: it is a real pattern, and sometimes exactly right. What makes it right is not the absence of duplication. It is joint ownership by two teams who genuinely collaborate, over a surface small enough that both can hold it in their heads — a set of identifiers and a couple of value types, not an entity with a lifecycle. A shared kernel that has grown a lifecycle has stopped being a kernel and become a third context that nobody is responsible for.
IF YOU REMEMBER ONE THING
A bounded context is not something you introduce. It is something you find, by noticing that a word already means two things — and then either drawing the line or paying for it in nullable columns.
A boundary drawn on a whiteboard costs nothing. It starts costing something at the point where a single unit of work has to span two of them, which is where the transaction boundary stops being an implementation detail.
Questions people also ask
5 QUESTIONSHow big should a bounded context be?
Large enough to hold rules and lifecycles that need one coherent model, and small enough that unrelated changes do not force that model to serve two jobs. A word that needs a qualifier — a sales customer, a dispatch customer — is one warning. Conflicting validation rules, ownership and change cadence can reveal a boundary even when the vocabulary is the same.
Is a bounded context the same as a microservice?
No, though they are often confused because a boundary is a sensible place to cut a service. A context is a boundary in the model; a service is a boundary in deployment. One context can be several services, and a small system can hold three contexts in one deployable with no network between them at all.
Can two contexts share a database?
They can share a database instance, though that still couples operations and capacity. Sharing the same tables couples the models more tightly: a schema change for one context can become a migration for both. Separate schemas with no cross-schema reads keep the model boundary clearer, even when both use one database server.
What if two teams disagree about what a word means?
That disagreement is evidence of a possible boundary. Check whether the teams also own different rules or lifecycles before deciding where to draw it. Then name each model and agree on the small API or event contract that crosses between them.
Do I need domain-driven design to use bounded contexts?
No. Comparing vocabulary, rules, lifecycles and ownership is useful even without adopting the wider pattern language. Context maps become useful when you need to describe how several such models exchange information.