DEV Community

Cover image for Bounded Contexts in a Real Codebase, Not a Diagram
Andrew Boyko
Andrew Boyko

Posted on

Bounded Contexts in a Real Codebase, Not a Diagram

Everyone draws the same diagram. A box for each part of the domain, a straight line from each box to a service, one arrow per box. It's the tidiest picture you'll ever ship in an architecture review, and the moment you try to build it exactly as drawn, it starts to lie to you.

I've now been on both sides of that diagram: the person who drew it with total confidence in year one, and the person who had to explain eighteen months later why the "Credentialing" box didn't match the credentialing service. The mismatch wasn't a mistake. It's what happens when you take a modeling concept and treat it like a deployment topology.

What a Bounded Context Is

The term comes from Eric Evans's 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. A bounded context is the boundary inside which a particular model, and the language that describes it, stays consistent. Outside that boundary, the same word can mean something else entirely, and that's fine, because you're no longer inside the model where it was defined.

The canonical example: "Customer" in a sales context means someone who can place an order. "Customer" in a support context means someone with a ticket history and an SLA. Same word, two different models, two different sets of rules about what's valid. A bounded context is where you stop pretending those are the same thing and give each one its own vocabulary, its own invariants, and its own boundary.

That's a modeling concept. It says nothing about how many services you run, how you deploy them, or who's on call for which one. Which is exactly where the diagram goes wrong.

The One-to-One Assumption, and Why It Breaks

The popular shorthand is: one bounded context, one microservice. It's easy to teach, easy to draw, and easy to sell in a slide deck. DDD writer Vlad Khononov put the actual relationship more precisely: "A Microservice is a Bounded Context, but not vice versa. Not every Bounded Context is a Microservice." A bounded context defines the largest boundary inside which a model can stay consistent. A microservice is a deployment decision made inside that boundary, and sometimes below it, not a mirror of it.

Here's the failure mode in practice. A team draws contexts too small, one per entity, and ends up with a dozen services that all have to call each other synchronously to do anything useful. That's not microservices. That's a monolith with the process boundaries moved into the network, which is strictly worse: same coupling, new latency, new failure modes.

The industry has a name for it now: the distributed monolith. It shows up most often when a team splits a system along its existing code layers (controllers, services, repositories) instead of along real domain boundaries, then calls the result "microservices" because there are multiple deployables.

The opposite mistake is just as common and less talked about: drawing one context for the entire domain, then building a single service with DDD vocabulary sprinkled over what is, underneath, still a big ball of mud. You get the ceremony of bounded contexts (aggregates, repositories, a ubiquitous language doc nobody updates) without the actual isolation that makes any of it worth doing.

The honest answer is that a bounded context and a service are two different kinds of boundary, and they only sometimes line up:

  • One bounded context can span multiple services, when the context is large enough that splitting it makes operational sense (different scaling needs, different release cadence, different team).
  • Multiple small, tightly related contexts can live inside one service, when splitting them would only add network calls without adding independence.
  • A context can be split across a synchronous API and an asynchronous event stream, which is not "two contexts," it's one context with two integration surfaces.

Deciding which of these is true for a given part of your domain depends on things a bounded context diagram can't tell you: team size, deployment cadence, scaling profile, and how often the boundary needs to move.

Where a Real Split Came From

I've spent the last year leading the technical direction of a healthcare workforce platform built around two products: a staff scheduling system and a credentialing system. On paper, that's two bounded contexts and, if you followed the popular rule, two services. In practice, the platform runs scheduling, credentialing, and integrations as separate services, and that third one is the interesting part.

Integrations wasn't its own bounded context in the domain sense. It doesn't own a piece of business vocabulary the way scheduling ("shift," "coverage," "swap request") or credentialing ("primary source verification," "expiration," "attestation") do. What it owns is a boundary problem: every external system we talk to (identity providers, document verification vendors, notification services) has its own model, its own failure modes, and its own pace of change, and none of that should leak into the scheduling or credentialing models.

That's the anti-corruption layer pattern from context mapping, given its own service instead of a library everyone imports. Once external integrations lived behind their own boundary, the scheduling and credentialing services stopped needing to know anything about a specific vendor's API shape, retry semantics, or auth flow. They call an internal contract; the integrations service is the only thing that has to change when a vendor changes theirs.

That's a service boundary that exists for operational reasons (isolate volatility, isolate failure, let one team own vendor relationships) layered on top of a modeling boundary that exists for domain reasons (keep "shift" and "verification" from ever meaning two things at once). Stabilizing the platform's architecture meant getting that distinction right, not drawing a cleaner box diagram.

When One Service Should Hold Multiple Contexts

The reverse case matters just as much, and it's the one people are more reluctant to admit out loud: sometimes the right call is to keep two related contexts in the same service, at least for a while.

Two sub-domains that change together, get deployed together, and are owned by the same two-person team don't need a network boundary between them. Splitting them early buys you nothing except a second CI pipeline, a second on-call rotation to staff, and a synchronous call where a function call used to be. The tradeoff to weigh isn't "is this technically a separate context," it's "does separating this reduce coordination cost, or just add infrastructure?"

Signals that it's still too early to split:

  • The two contexts are always deployed in the same release.
  • One team owns both, and that's not changing soon.
  • The only reason for the split is "microservices are the right architecture," not a concrete scaling or ownership problem.

Signals that it's worth the network hop:

  • The contexts have genuinely different scaling profiles (one is read-heavy and cacheable, the other is write-heavy and transactional).
  • Different teams need to own them, and shared deployment is actively slowing both down.
  • One context's failure shouldn't be able to take the other down, and today it can.

None of these are visible on a static bounded-context diagram. They're operational facts about the system you're running right now, and they change over time, which is why context boundaries and service boundaries are allowed to diverge and then re-converge later.

Diagram showing Scheduling and Credentialing services connecting through an internal contract to an Integrations anti-corruption layer, which connects via vendor-specific contracts to an identity provider, verification vendor, and notification service

The Seams: Context Mapping Patterns Worth Knowing

Once you accept that contexts and services don't automatically line up, the interesting question becomes: what sits at the seam? DDD's context mapping patterns answer that, and three of them cover most real cases.

Shared kernel is when two contexts agree to share a small, explicitly maintained subset of the model, usually because splitting it would cost more than the coordination overhead of keeping it in sync. It's the pattern to reach for rarely and deliberately, not the default.

Customer/supplier is when one context's team can influence the other's roadmap because they depend on it, and the supplier team treats that dependency as a real constraint on what they ship. This is a people pattern as much as a code pattern: it only works if the supplier team honors it in practice.

Anti-corruption layer is the one that shows up the most in practice, and it's the pattern the integrations service above is built on. A translation boundary sits between two models so that neither one has to bend to accommodate the other. A small TypeScript sketch of the shape:

src/integrations/verification/anti-corruption-layer.ts

// The vendor's shape. We don't control this and it can change without warning.
type VendorVerificationResponse = {
  verification_status_code: "V1" | "V2" | "V3";
  subject_ref: string;
  checked_at_utc: string;
};

// Our domain's shape. This is the only thing Credentialing ever sees.
type PrimarySourceVerification = {
  status: "pending" | "verified" | "failed";
  providerId: string;
  verifiedAt: Date | null;
};

function translate(vendorResponse: VendorVerificationResponse): PrimarySourceVerification {
  const statusMap: Record<VendorVerificationResponse["verification_status_code"], PrimarySourceVerification["status"]> = {
    V1: "pending",
    V2: "verified",
    V3: "failed",
  };

  return {
    status: statusMap[vendorResponse.verification_status_code],
    providerId: vendorResponse.subject_ref,
    verifiedAt: vendorResponse.verification_status_code === "V2" ? new Date(vendorResponse.checked_at_utc) : null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Nothing clever is happening here on purpose. That's the point: the translation function is the entire cost of keeping the vendor's model out of the domain. When the vendor renames a field or adds a fourth status code, this function is the only place that changes.

Finding the Boundaries Before You Build Anything

If you're doing this for the first time, don't start by drawing boxes. Start with event storming: get the people who understand the domain in a room (or a shared board), and map out the significant business events on a timeline, in the language the business uses for them, not the language your database schema uses. Boundaries tend to reveal themselves as clusters: events that trigger each other tightly belong together, and gaps where one cluster hands off to another, often with a change in vocabulary, are your candidate context boundaries.

It's slower than opening a whiteboard and drawing four boxes from memory. It's also the difference between a boundary that reflects how the business actually works and a boundary that reflects how the code happened to be organized when you looked at it.

Where the Boundary Should Have Been

If I were drawing that diagram again for the platform I'm working on now, I wouldn't add a fourth box for "Integrations" as if it were a peer domain concept next to scheduling and credentialing. I'd draw it as what it is: an anti-corruption layer that earned its own deployable because of operational concerns, not because the domain model demanded a third bounded context. The distinction matters, because the next time someone asks "should this be its own service," the domain diagram won't answer the question. The operational tradeoffs will.


Originally published at andriiboyko.com.

Top comments (0)