Understanding Bounded Contexts: A Pragmatic Approach

Understanding Bounded Contexts: A Pragmatic Approach
When building complex applications, one of the hardest challenges isn't writing code—it's organizing it so it doesn't become a tangled mess six months later. This is where Bounded Contexts from Domain-Driven Design become essential.
What is a Bounded Context?
A Bounded Context is a logical boundary where specific terms and models have clear, consistent meanings.
Think of it like language barriers between countries. The word "gift" means "present" in English but "poison" in German. Both are correct—within their context.
In software: The same principle applies. The word "User" might mean completely different things in different parts of your application.
The Problem: Semantic Overload
Imagine building a SaaS platform. The word "User" gets used everywhere:
- Authentication Module: "User" = login credentials (email, password)
- CRM Module: "User" = a potential customer or lead
- Billing Module: "User" = the account being charged
- Permissions Module: "User" = a set of roles and access rights
Without clear boundaries, developers create a single User entity that tries to handle all of these responsibilities. This violates the Single Responsibility Principle at an architectural level and leads to:
- Massive, unmaintainable classes
- Tight coupling across modules
- Fear of changing anything because it might break something else
Bounded Contexts: The Solution
Instead of one giant model, we create separate contexts where each term has one precise meaning.
Example: Three Contexts
Let's look at how a multi-tenant application might be organized:
1. Identity Context
Purpose: Who is this person?
export class User {
id: string;
email: string;
passwordHash: string;
}Language: User, Credentials, Authentication Responsibility: Managing login and account security
2. Billing Context
Purpose: Who pays and how much?
export class Customer {
id: string;
userId: string;
stripeCustomerId: string;
billingEmail: string;
}Language: Customer, Subscription, Invoice Responsibility: Managing payments
3. Permissions Context
Purpose: What can this person do?
export class Member {
id: string;
userId: string;
organizationId: string;
role: string;
}Language: Member, Role, Permission Responsibility: Managing access control
Notice: Each context has its own model. The Identity context doesn't know about billing. The Billing context doesn't handle permissions.
Why This Matters
1. Independent Evolution
You can completely rewrite your authentication system (switch from JWT to OAuth) without touching your billing or permissions code.
2. Clear Ownership
Different teams can own different contexts without conflicts.
3. Mental Clarity
When debugging a payment issue, you look in the Billing context. Not scattered across 50 files.
4. Microservice Ready
Each context can eventually become its own service if needed.
Implementing Bounded Contexts in NestJS
Folder Structure
src/
├── identity/
│ ├── entities/
│ ├── identity.service.ts
│ └── identity.module.ts
│
├── billing/
│ ├── entities/
│ ├── billing.service.ts
│ └── billing.module.ts
│
└── permissions/
├── entities/
├── permissions.service.ts
└── permissions.module.tsCommunication Between Contexts
Contexts aren't isolated—they need to talk. Here's how:
Pattern 1: Service-to-Service (Direct)
@Injectable()
export class BillingService {
constructor(private identityService: IdentityService) {}
async createCustomer(userId: string) {
const user = await this.identityService.findOne(userId);
const customer = await this.stripe.customers.create({
email: user.email,
});
}
}Key Rule: Import the Service, never the Repository. The service is the "public API."
Pattern 2: Events (Decoupled)
async register(email: string, password: string) {
const user = await this.repo.save({ email, password });
this.eventEmitter.emit('user.registered', { userId: user.id });
return user;
}
@OnEvent('user.registered')
async handleUserRegistered({ userId }) {
await this.billingService.createCustomer(userId);
}Benefit: The Identity context has zero knowledge of Billing existing.
Pragmatic Rules (Not Strict DDD)
You don't need to follow every DDD pattern. Here are the essentials:
-
One Public Service per Context
- Other modules talk to
IdentityService, notUserRepository
- Other modules talk to
-
Store IDs, Not Objects
typescriptcustomerId: string; -
Use Events for Side Effects
- When Context A's action should trigger something in Context B, use events
-
Keep Language Consistent
- Inside the Billing context, always say "Customer," not "User"
When NOT to Use Bounded Contexts
Don't over-engineer:
- Small apps: A 3-table CRUD app doesn't need this
- Highly coupled domains: If two concepts always change together, they're probably one context
Real-World Impact
Before:
class User {
email: string;
password: string;
stripeId: string;
subscription: Subscription;
roles: Role[];
organizations: Organization[];
}After:
class User {
id: string;
email: string;
passwordHash: string;
}
class Customer {
userId: string;
stripeId: string;
}
class Member {
userId: string;
role: string;
}Each class has one job. Changes are isolated. Life is simpler.
Conclusion
Bounded Contexts aren't academic theory—they're a practical pattern for keeping your NestJS application maintainable as it grows.
The core idea is simple: Draw boundaries around concepts that have different meanings or change for different reasons.
Start by asking: "If I change this, what else has to change?" If the answer is "nothing," you've found a natural boundary for a context.
Your future self (and your team) will thank you.