Understanding Bounded Contexts: A Pragmatic Approach

2025-12-24
8 min read
ArchitectureDDDNestJSTypeScript
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?

typescript
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?

typescript
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?

typescript
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

text
src/
├── identity/
│   ├── entities/
│   ├── identity.service.ts
│   └── identity.module.ts

├── billing/
│   ├── entities/
│   ├── billing.service.ts
│   └── billing.module.ts

└── permissions/
    ├── entities/
    ├── permissions.service.ts
    └── permissions.module.ts

Communication Between Contexts

Contexts aren't isolated—they need to talk. Here's how:

Pattern 1: Service-to-Service (Direct)

typescript
@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)

typescript
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:

  1. One Public Service per Context

    • Other modules talk to IdentityService, not UserRepository
  2. Store IDs, Not Objects

    typescript
    customerId: string;
  3. Use Events for Side Effects

    • When Context A's action should trigger something in Context B, use events
  4. 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:

typescript
class User {
  email: string;
  password: string;
  stripeId: string;
  subscription: Subscription;
  roles: Role[];
  organizations: Organization[];
}

After:

typescript
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.