Boundaries are hard to get right. Not only between the big parts of a system, but anywhere we draw a line: between classes, objects, even methods.
I've spent a lot of time describing systems, both new and existing ones. I try not to throw terms like "Domain-Driven Design" at people, because they are open to a lot of interpretation and there are many nuances to them. But then, how do you help someone understand boundaries, whether they have a technical background or not?
I start with the parts of the business they already know: Acquisition, Distribution, Billing. Then we explore which of those parts depends on which, and in what way. Those dependencies play an important role in how we build products and design systems, and making them visible is where I've had the most success. The words that help us understand them are upstream and downstream.
This article goes a bit deeper than those conversations, so the parts need a name. Domain-Driven Design calls them bounded contexts: boundaries within which one model and its language stay consistent. A bounded context can be a single service, or a module inside one. Bounded contexts rarely stand alone: each one depends on others, and others depend on it.
What follows explains which way those dependencies should point, how to tell the sides apart, why that matters, how to spot a boundary that has started to leak, and why dependencies must never go in a circle.
Upstream vs. Downstream
Think of a river: the downstream part only gets the water that comes from upstream.
Upstream
Upstreams provide capabilities that Downstreams can use. Upstreams should not depend on Downstreams. Ideally they don't know their Downstreams exist; if they do, they still contain none of their concepts.
Downstream
Downstreams consume what Upstreams provide. Downstreams depend on Upstreams.
The Request Paradox (Data vs. Control Flow)
Data, business value and events flow downstream, like water. Network requests flow the other way: the Downstream calls up to the Upstream to fetch data or to ask it to do something. Two things cross every boundary, in opposite directions. Calls go up, data comes down.
That is the paradox, and it is why context maps often end up drawn upside down. Follow the request and you draw the arrow from caller to callee. But the arrow on a context map shows the dependency, and the dependency follows the water, not the request. So if the call doesn't settle it, what does?
Telling the sides apart
The contract decides the direction, not the call. The Upstream defines the contract, and the Downstream adapts to it. The call is still a good hint, because the caller is usually downstream. When the two disagree, the contract wins.
Webhooks look like an exception, but they aren't. A payment provider calls your endpoint, so the call travels from Upstream to Downstream. Yet the provider defines the payload and you adapt to it. You registered your URL with it: the Downstream subscribes, and the Upstream delivers to whatever address it was given. It knows where to send, not who you are or what your model looks like.
Rule of thumb. Start with the network calls, but don't stop there. Ask: whose model does the other one have to know about? The side that can be described without the other is the Upstream; the side that can't is the Downstream. If neither can be described without the other, the boundary is either leaking or in the wrong place.
Why it matters
Keeping an Upstream unaware of its Downstreams pays off on both sides of the boundary:
- Reuse. The Upstream can serve any Downstream, not only the one it was first built for.
- Replaceability. The Upstream can be replaced (by a vendor, a rewrite, a new platform), and Downstreams only change an adapter.
- Independent testing. The Upstream can be tested without fixtures from any Downstream.
- A stable foundation. Downstreams build on a language that doesn't change just because one Downstream changed.
- Contained blast radius. A change in one Downstream can't break the Upstream or its other Downstreams.
- Team autonomy. A Downstream can change without the Upstream team in its planning meeting, and the Upstream can change its internals freely. Only changes to the Upstream's contract need coordinating, because every Downstream adapts to it.
Signs of a leaking boundary
The boundary leaks the moment downstream concepts start showing up inside the Upstream:
- Downstream identifiers in upstream data or APIs. The Upstream stores or accepts IDs that only mean something to one Downstream.
- Downstream-specific APIs. The Upstream exposes endpoints or accepts payloads shaped around one Downstream's model.
- Downstream state in upstream events. The Upstream's events carry statuses or fields that describe a Downstream's state, not its own.
- Downstream rules in upstream logic. The Upstream checks conditions that belong to a Downstream's business rules.
- Releases driven by a Downstream. The Upstream has to change just because one Downstream changed its model or request format. This is the Request Paradox going wrong: the call travels upstream, and the Downstream's concepts travel with it.
When you spot one, the fix is the same: move the downstream-specific knowledge back into the Downstream, and let the Downstream hold the reference to the Upstream, never the other way round.
Avoid dependency cycles
Getting the direction right between two bounded contexts is not enough: a bounded context must never end up downstream of itself.

Cycle: Spring → River → Delta → Spring. Spring ends up downstream of itself.

No cycle: every arrow points the same way.
A cycle cancels the benefits above: changes travel around the loop and come back, and concepts leak in through a third bounded context. Two bounded contexts that depend on each other directly are the smallest version of the same problem.
To break one, find the arrow that points the wrong way. Usually one bounded context should coordinate the others; if two bounded contexts can't be described without each other, they may be one bounded context.
Example: Shipping and Ordering
Shipping and Ordering show up in almost any business that sells things and delivers them.
- Shipping is the Upstream
- Ordering is the Downstream
That might surprise people at first, because Ordering is the one that "kicks things off". The sections below show why.
The Request Paradox in action
Ordering starts the process and makes the call, and Shipping is the Upstream. Calls go up, data comes down.
Ordering calls Shipping
Request travels upstream.
When an Order is paid, Ordering asks Shipping to create a shipment: "ship these parcels to this address". It speaks Shipping's language: parcels, weights, an address. It does not send an Order object and expect Shipping to figure out what to do with it. Ordering saves the ID of the shipment to later get information about that shipment.
Shipping publishes what happened
Data flows downstream.
ShipmentCreated, ShipmentDispatched, ShipmentDelivered. These events are about shipments. They carry a shipment id, a carrier, timestamps. They do not carry an order id, an order status, or a customer's purchase history.
Ordering consumes those events
The Downstream adapts.
Ordering translates events into its own concepts: ShipmentDelivered becomes "Order fulfilled". Ordering keeps the mapping order ↔ shipment on its side, because Ordering is the one that cares about it.
Telling the sides apart, in practice
Shipping defines the contract (the CreateShipment request and the Shipment… events), and Ordering adapts to it. Here the hint and the contract agree: the caller, Ordering, is the Downstream.
| Shipping (Upstream) | Ordering (Downstream) | |
|---|---|---|
| Purpose | Move a parcel from an origin to a destination address, through a carrier, and report where it is. | Turn what a customer bought into a fulfilled purchase: items, prices, payment, and getting the goods to the customer. |
| Concepts | Shipment, Parcel, Weight, Dimensions, Address, Carrier, Label, Tracking number, Delivered. | Order, Line item, Customer, Total, Payment, Fulfilment status, and Shipment (borrowed from Shipping). |
| What it knows about the other | Nothing. Shipping has no idea what an Order is. It ships parcels for whoever asks: a sales order, a warranty replacement, a sample sent to a partner, a return. | Quite a lot. Ordering knows that a Shipment exists, how to request one, and what "Delivered" means for an Order's fulfilment status. |
Shipping can be fully described without the word "Order" ever appearing. Ordering cannot be described without Shipping. That is the rule of thumb at work: the side that can be described without the other is the Upstream.
Why it matters, in practice
Applied to this example, the benefits of a clean Upstream mean Shipping can ship returns, warranty replacements and partner samples, not just orders; it can be replaced by a carrier platform behind an adapter; it can be tested without an Order fixture; and only changes to Shipping's contract need coordinating with Ordering.
Signs of a leaking boundary, in practice
Here is what each of the signs of a leaking boundary looks like between Shipping and Ordering:
| Sign | In Shipping and Ordering |
|---|---|
| Downstream identifiers in upstream data or APIs | An order_id column on the Shipment table, or an order_id parameter in Shipping's REST API |
| Downstream-specific APIs |
POST /shipments/from-order taking an Order payload |
| Downstream state in upstream events |
ShipmentDelivered carrying orderStatus: FULFILLED
|
| Downstream rules in upstream logic | Shipping checking "is this order paid?" before printing a label |
| Releases driven by a Downstream | Shipping redeploying because the Order's shape changed |
Avoiding dependency cycles, in practice
To see why dependency cycles are a problem, add a third bounded context, Billing, and a reasonable rule: don't dispatch a parcel until it has been paid for.
The mistake
Billing listens to OrderPlaced, and Shipping waits for InvoicePaid. Shipping is now downstream of itself: a change to the Order travels through Billing to Shipping and back to Ordering, and an order_id reaches Shipping through Billing.
The fix
Ordering coordinates. It asks Billing for an invoice in Billing's language and, once InvoicePaid arrives, asks Shipping to ship. Neither Upstream knows the other exists.
Further reading
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003), Part IV, "Strategic Design".
- Eric Evans, Domain-Driven Design Reference (2015): the context-mapping patterns in summary form.
- Vaughn Vernon, Implementing Domain-Driven Design (2013), chapter 3, "Context Maps".









Top comments (0)