Bounded Contexts in a Real Codebase
The hardest part about bounded contexts isn't understanding the concept — it's finding them in a codebase that already exists. Real codebases don't announce their contexts. They express them through pain: the User class with 47 fields, the service method that touches six tables, the PR that breaks three teams every time it merges.
The boundaries are already there. They're just implicit. Your job is to make them explicit.
What a Boundary Actually Looks Like
A bounded context is a linguistic boundary first. Inside it, a word has exactly one meaning. Outside it, the same word might mean something different.
In a typical e-commerce system, Order means something completely different to the warehouse team (a pick list), the finance team (an invoice trigger), and the customer service team (a thing to cancel). If your codebase has one Order class shared everywhere, you've crossed context boundaries without knowing it.
// Anti-pattern: one Order god-class serving multiple contexts
public class Order {
private Long id;
private Customer customer; // CRM concept
private List<LineItem> lineItems; // warehouse concept
private PaymentStatus paymentStatus; // finance concept
private ShippingLabel shippingLabel; // logistics concept
private List<SupportTicket> tickets; // customer service concept
// 43 more fields...
}Each of those clusters belongs in a different context.
Finding Boundaries: Three Signals
Signal 1 — The ubiquitous language breaks down. When you use the same word with a domain expert and they look confused, or when two domain experts use the same word to mean different things, you've found a boundary.
Ask yourself: can I explain what "order" means to a warehouse manager and a CFO using the exact same definition? If not, the model is lying.
Signal 2 — The same data is transformed every time it crosses. If you write CustomerDto, CustomerView, CustomerSummary, CustomerRecord all pointing at the same underlying table, your contexts are leaking. Each of those names is a context trying to break free.
Signal 3 — Teams own different parts of the same model. Org chart friction is a context map in disguise. If two teams argue over who can change the Product class, it's because they each have a legitimate but different model of what a product is.
Same physical product, four different models. They share only an identifier — not fields, not behaviour, not lifecycle.
Enforcing Boundaries in Java
Once you've found the context, enforce it with packages. Do not share domain objects across context packages. Use a shared identifier (a value object wrapping a UUID or SKU) as the only crossing point.
// Catalog context owns the product definition
package com.example.catalog.domain;
public class Product {
private final ProductId id;
private final String name;
private final String description;
private final List<ProductImage> images;
public ProductId getId() { return id; }
// No pricing, no stock, no shipping fields here
}// Inventory context has its own concept, mapped from catalog's ID
package com.example.inventory.domain;
public class StockItem {
private final ProductId productId; // shared identifier, not shared object
private final Sku sku;
private final Quantity quantityOnHand;
private final ReorderThreshold reorderPoint;
public boolean needsReorder() {
return quantityOnHand.isBelow(reorderPoint);
}
}The ProductId type is a shared kernel — a tiny value object that crosses the boundary. Everything else stays inside its context.
Enforcing Boundaries in TypeScript
// packages/catalog/src/domain/Product.ts
export class Product {
constructor(
readonly id: ProductId,
readonly name: string,
readonly description: string,
) {}
}
// packages/inventory/src/domain/StockItem.ts
// Does NOT import from catalog package's domain
import { ProductId } from '@company/shared-kernel';
export class StockItem {
constructor(
readonly productId: ProductId,
readonly sku: Sku,
private quantityOnHand: Quantity,
readonly reorderPoint: Quantity,
) {}
needsReorder(): boolean {
return this.quantityOnHand.isLessThan(this.reorderPoint);
}
}In a monorepo, enforce this with lint rules — no imports from packages/catalog/domain inside packages/inventory. The compiler won't stop you, but ESLint will.
The Context Map: Just Draw It
Don't overthink this. A context map is a box-and-arrow diagram that shows:
- Which contexts exist
- What relationship connects them (who's upstream, who's downstream)
- How data crosses (event, REST call, shared DB table)
Draw it with your team. Put it in a CONTEXT_MAP.md in your repo root. Update it when you add a new integration. This is the document that stops new engineers from making bad coupling decisions on their first week.
Starting the Move
You don't need a rewrite. The incremental path:
- Name the contexts you believe exist. Write them down.
- Find the first god-class and identify which fields belong to which context.
- Create context-specific packages. Copy (don't move yet) the fields into new classes.
- Update one consumer at a time to use the new context-specific class.
- When all consumers are updated, delete the fields from the god-class.
- Repeat.
Each step is a safe, reviewable PR. The big-bang rewrite that introduces all bounded contexts at once is how you break production.
Key Takeaways
- Bounded contexts are linguistic boundaries first — when the same word means different things to different domain experts, you've found a boundary.
- God-classes with mixed concerns are implicit bounded contexts waiting to be made explicit.
- The only thing that crosses a context boundary cleanly is a shared identifier — not shared domain objects.
- Enforce boundaries with package structure and lint rules; the compiler alone will not save you.
- A context map is a live diagram, not a one-time artifact — update it whenever integrations change.
- Make the move incrementally: copy fields into context-specific classes, migrate consumers one at a time, then remove the old fields.