Bounded Contexts: Drawing Lines That Matter
Bounded contexts are clear lines around models where language and rules stay consistent. They're how you manage complexity in large systems, enabling teams to work independently and preventing the cognitive overload that kills big codebases.
What a Bounded Context Actually Is
A bounded context is a boundary around a model where the ubiquitous language is consistent and well-defined. Within that boundary, a term means exactly one thing. Outside the boundary, the same word might mean something completely different.
Think about the word "customer" in different parts of your business:
- In Sales Context, a customer is a prospect or active buyer with a pipeline and deal stage.
- In Support Context, a customer is someone with open tickets, support level, SLA terms.
- In Billing Context, a customer is an account with payment history, subscription status, invoice records.
Same word. Three completely different definitions tied to three completely different models. Each context has boundaries. Inside those boundaries, rules are consistent. Cross those boundaries and you need translation.
A bounded context is:
- A subsystem or module with clear ownership
- A place where one ubiquitous language applies
- Responsible for one major business capability
- Can be worked on independently by a team
- Has its own data and persistence strategy (ideally)
Why Boundaries Matter
Cognitive Load
Software complexity is fundamentally about how much you have to keep in your head. A bounded context is small enough to understand fully. The entire model fits in working memory.
// A well-bounded Customer context cares about this:
class Customer {
constructor(id, email, name) {
this.id = id;
this.email = email;
this.name = name;
}
updateEmail(newEmail) {
this.email = newEmail;
}
updateName(newName) {
this.name = newName;
}
}
// A poorly-bounded Customer tries to handle everything:
class Customer {
constructor(id, email, name) {
this.id = id;
this.email = email;
this.name = name;
this.invoices = [];
this.supportTickets = [];
this.purchaseHistory = [];
this.recommendations = [];
this.subscriptionStatus = null;
this.creditScore = null;
this.shippingAddresses = [];
}
// Hundreds of methods...
}The first is a bounded context. You can understand it completely. The second is a mess—it tries to be everything and becomes nothing.
Parallel Development
Multiple teams can't work on the same class safely. But they can work on different bounded contexts in parallel if those contexts have clear boundaries and defined interaction rules. This is the same principle that makes microservices work — independent deployability through clear boundaries.
Team A owns Customer context. Team B owns Billing context. They agree: "Billing gets customer ID, email, and subscription status through a defined interface. That's it." Team A can work independently without coordinating everything with Team B.
Flexibility and Evolution
When boundaries are clear, you can change one context without forcing changes everywhere else. You can even replace one context with a different implementation.
// If your payment processing moves from internal system to Stripe,
// only the Billing context changes
class PaymentProcessor {
processPayment(payment) {
// Old: internal system
// New: Stripe API
// The change is isolated
}
}
// Order context doesn't care how payment works,
// it just knows "payment succeeded" or "payment failed"Team Structure (Conway's Law)
Your architecture mirrors your organization. Bounded contexts naturally align with teams. If you have three teams, you probably have three main bounded contexts. If you try to force one team to own multiple contexts, they'll fight. If you force one context across multiple teams, they'll coordinate forever.
How to Identify Bounded Contexts
Listen for Language Breakdowns
Domain experts use the same word differently in different parts of the business. That's a context boundary.
In an insurance company:
- Claims team says "claim" means a submitted incident with investigation and resolution.
- Settlement team says "claim" means a validated incident with a payout amount.
- Fraud team says "claim" means a potential fraud case with risk score.
Three contexts. One word with three meanings. The breakdown in communication is your map.
Look for Independent Lifecycles
Does an entity have a lifecycle that's independent of other entities? That suggests a boundary.
// An Order has a clear lifecycle that's different from Inventory
// Order lifecycle: created → placed → processing → shipped → delivered
// Inventory lifecycle: restocked → allocated → picked → shipped
class Order {
status; // Order-specific states
}
class InventoryAllocation {
status; // Inventory-specific states
}
// These have different rules and different lifecycles
// Likely different bounded contextsFind Natural Team Boundaries
Ask: "If I had to split this into teams, where would the splits be?" Those are usually good context boundaries.
Look for Different Persistence Strategies
If two aggregates have completely different persistence and query requirements, they might be in different contexts.
// Product Catalog needs fast, complex queries:
// - "Find products matching these filters"
// - "Get product with related products"
// - Typically denormalized for read performance
// Order needs transactional consistency:
// - "Create order atomically with all line items"
// - Typically normalized
// Different persistence strategies suggest different contextsTrace Data Flow
Draw how data flows through your system. Natural clusters usually emerge. Those clusters often are bounded contexts.
Mapping Bounded Contexts
Once you've identified contexts, you need to define how they interact. This is context mapping.
The Anticorruption Layer (ACL)
Use when you depend on another context but can't accept its model because it's poorly designed or external.
The ACL is a translation layer that:
- Accepts data from the upstream context in its format
- Translates to your domain model
- Presents your clean interface to internal code
// External payment provider (we don't control it)
class PaymentProviderAPI {
submitTxn(data) {
// Expects: {txn_id, amt, vnd_code, mrch_code}
// Returns: {status, ref_num, ts}
}
}
// Our Order context model (clean, clear)
class Payment {
constructor(orderId, amount) {
this.orderId = orderId;
this.amount = amount;
}
}
class PaymentProcessingResult {
constructor(success, referenceNumber) {
this.success = success;
this.referenceNumber = referenceNumber;
}
}
// Anticorruption Layer (adapter)
class ExternalPaymentAdapter {
constructor(providerAPI, config) {
this.provider = providerAPI;
this.vendorCode = config.vendorCode;
this.merchantCode = config.merchantCode;
}
processPayment(payment) {
// Translate from our model to theirs
const providerRequest = {
txn_id: payment.orderId,
amt: payment.amount.inCents(),
vnd_code: this.vendorCode,
mrch_code: this.merchantCode
};
const providerResponse = this.provider.submitTxn(providerRequest);
// Translate back to our model
return new PaymentProcessingResult(
providerResponse.status === 'approved',
providerResponse.ref_num
);
}
}
// Order context uses only our clean interface
class OrderService {
constructor(paymentAdapter) {
this.paymentAdapter = paymentAdapter;
}
placeOrder(order) {
const payment = new Payment(order.id, order.total);
const result = this.paymentAdapter.processPayment(payment);
if (result.success) {
order.markAsPaid(result.referenceNumber);
}
}
}The ACL protects your domain from being infected by external systems. When the payment provider changes their API, only the ACL changes. Your order logic is untouched.
Shared Kernel
When two contexts genuinely need to share code, create a shared library they both depend on. Keep it minimal and stable.
// Shared Kernel - used by both Order and Inventory contexts
// src/shared-kernel/Money.js
export class Money {
constructor(amount, currency = 'USD') {
this.amount = amount;
this.currency = currency;
}
plus(other) {
if (this.currency !== other.currency) {
throw new Error("Can't add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
minus(other) {
return this.plus(new Money(-other.amount, other.currency));
}
equals(other) {
return this.amount === other.amount && this.currency === other.currency;
}
}
// Order context uses Money
import { Money } from '../shared-kernel/Money';
class Order {
total() {
return this.items.reduce(
(sum, item) => sum.plus(item.price),
new Money(0)
);
}
}
// Inventory context uses Money
class InventoryValuation {
totalValue() {
return this.allocations.reduce(
(sum, alloc) => sum.plus(alloc.product.price.times(alloc.quantity)),
new Money(0)
);
}
}
// Rule: shared kernel is read-only for most code
// Changes require agreement between teamsShared kernel dangers:
- Teams don't coordinate and break each other
- The kernel grows and becomes tightly coupled
- It becomes the dumping ground for shared code
Keep it small. Prefer duplication over shared kernel coupling unless the code is genuinely stable.
Customer-Supplier (Negotiated Dependency)
When one context depends on another, establish a formal interface. The supplier commits to stability; the customer can plan around that.
// Supplier: Catalog Context provides stable interface
export class CatalogService {
getProductById(id) {
return {
id: id,
name: string,
description: string,
basePrice: Money,
inStock: boolean,
discontinued: boolean
};
}
searchProducts(filters) {
// returns Product[]
}
}
// Customer: Order Context depends on Catalog
class OrderService {
constructor(catalogService) {
this.catalog = catalogService;
}
addLineItem(orderId, productId, quantity) {
const product = this.catalog.getProductById(productId);
if (product.discontinued) {
throw new Error("Product is discontinued");
}
// Use the well-defined interface
const order = this.repository.getById(orderId);
order.addLineItem(product, quantity);
}
}
// The interface is the contract.
// Catalog commits to returning objects with these fields.
// Catalog commits to not breaking this interface.
// Order plans around this stability.This pattern works when upstream is stable and you can rely on it. The supplier takes responsibility for not breaking downstream.
Conformist
Downstream just accepts what upstream provides. No translation, no adaptation. This works when upstream is powerful and well-designed.
// Upstream: Identity Context provides User objects
export class User {
constructor(id, email, verified, createdAt) {
this.id = id;
this.email = email;
this.verified = verified;
this.createdAt = createdAt;
}
}
// Downstream: Order Context uses User directly (conformist)
class Order {
constructor(id, user) {
this.id = id;
this.user = user; // No translation, no adaptation
this.customerId = user.id;
}
canPlace() {
return this.user.verified; // Use upstream model directly
}
}Conformist is simple but creates coupling. Upstream changes affect downstream. Use when upstream is stable and powerful (like AWS or a critical internal system).
Modeling Context Relationships
Typical e-commerce system
Catalog Context
(Products,
Order Context
(Orders, LineItems)
Common Mistakes
Over-Granular Contexts
One context per aggregate is too fine-grained. A context should contain multiple related aggregates.
// Too granular - each class is a context
// This defeats the purpose
class UserContext { }
class OrderContext { }
class LineItemContext { }
// Right-sized - related aggregates grouped
class OrderContext {
// Contains Order and LineItem aggregates
// They're tightly related and change together
}Under-Granular Contexts
One context for the whole system. You've defeated the entire purpose of bounded contexts.
// Everything in one context
class AppContext {
users = [];
orders = [];
products = [];
payments = [];
// ... 5,000 lines of code
}
// Better: Multiple contexts with clear boundaries
class UserContext { }
class OrderContext { }
class ProductContext { }Ignoring Team Structure
Trying to force a technical boundary that doesn't match how teams are organized. Teams will fight boundaries they don't own.
Leaky Boundaries
One context reaches across into another and modifies its data directly. This breaks encapsulation.
// Bad: Order reaches into Inventory directly
class Order {
place() {
for (let item of this.items) {
// Direct manipulation of another context
this.inventoryAllocation.quantity -= item.quantity;
}
}
}
// Good: Order publishes event, Inventory responds
class Order {
place() {
this.status = 'placed';
this.domainEvents.push(new OrderPlacedEvent(this.id, this.items));
}
}
class InventoryService {
onOrderPlaced(event) {
for (let item of event.items) {
// Inventory makes its own decision
const allocation = this.allocate(item.productId, item.quantity);
}
}
}Evolving Bounded Contexts
Contexts aren't static. As your business grows, they change.
When contexts are getting too large, split them. When related contexts are constantly coordinating, merge them. This is normal and healthy.
Timeline of an e-commerce company
E-commerce App
Order Context
+ Product Ctx
Order
Context
Product
Context
Inventory
Context
Product
Catalog
Search
Context
Order
Context
FAQ
How many bounded contexts should I have?
Usually 3-8 for a typical business application. Fewer than 3 and you're not getting the benefits. More than 8 and coordination becomes complex. This isn't a hard rule, just a pattern.
Can I have contexts within contexts (nested)?
Avoid nesting. It creates confusion about which context owns what. Prefer flat context maps with clear relationships.
Should bounded contexts align with microservices?
Often, but not always. One bounded context can be one microservice, but you might have multiple contexts in one service early on, and split them later.
How do I handle transactions across contexts?
Usually you don't. Each context manages its own transactions. For operations spanning contexts, use sagas or eventual consistency patterns.
What if two contexts need the exact same aggregate?
They don't. They need their own models that represent the same concept differently. Use an ACL to translate.
Primary Sources
- Foundational text on ubiquitous language, domain models, and bounded context boundaries. Domain-Driven Design
- Sam Newman's guide to designing microservices within organizational and system boundaries. Building Microservices
- Practical patterns for implementing domain-driven design in real enterprise systems. Implementing DDD
- Fowler's patterns for organizing application architecture across system layers. EAA Patterns
- Guide to refactoring techniques for safely evolving designs and system boundaries. Refactoring
- Strategies for safely modifying and testing existing systems without breaking them. Working with Legacy Code
More in this hub
Bounded Contexts: Drawing Lines That Matter
3 / 10Previous
Article 2
Domain-Driven Design (DDD) Deep Dive: Building Software Around Your Business
Next
Article 4
CQRS: Separating Reads from Writes
Also in this hub
Get Started with Bitloops.
Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.
curl -sSL https://bitloops.com/install.sh | bash