Domain-Driven Design in Practice: How We Draw Module Boundaries
Practical DDD patterns for TypeScript and NestJS. Drawing module boundaries, EventBus as anti-corruption layer, when DDD is overkill, shared kernels, and refactoring toward boundaries.
DDD Is Not About the Blue Book
Most DDD articles start with aggregates, value objects, and domain events from Eric Evans' book. That's theory. In practice, the most valuable concept from DDD is one thing: boundaries.
Where does module A end and module B begin? What data does each module own? How do they communicate? When you get boundaries right, the codebase stays clean as it grows. When you get them wrong, every feature touches every module, and refactoring becomes impossible.
We've drawn module boundaries in multiple production systems: a 6-module Vendure plugin, an 8-service ticketing platform, and a PIM with 13 event subscribers and complex worker orchestration. This article covers the practical patterns. For event-driven communication across boundaries, see our event-driven architecture guide. For Vendure-specific patterns, see our Vendure plugin architecture guide.
The Dependency Rule
The simplest way to identify module boundaries: draw a dependency graph. If module A imports something from module B and module B imports something from module A, you have a circular dependency. The boundary is wrong.
// BAD: circular dependencies
WishlistService imports LoyaltyService (to award points)
LoyaltyService imports WishlistService (to check wishlist for bonus)
// GOOD: event-based communication
WishlistService emits WishlistItemAddedEvent
LoyaltyService subscribes to WishlistItemAddedEvent (awards points)
LoyaltyService emits PointsAwardedEvent
WishlistService doesn't care (no subscription needed)
The rule: dependencies flow in one direction. If two modules need to communicate bidirectionally, use events. Events are unidirectional by design. The emitter doesn't know (or care) who subscribes.
How to Check
# Find circular imports in a TypeScript project
npx madge --circular src/
# Find cross-module imports
grep -rn "from '.*wishlist.*'" src/modules/loyalty/
grep -rn "from '.*loyalty.*'" src/modules/wishlist/
If these commands return results, you have boundary violations. Fix them by extracting the shared concern into a separate module or replacing the import with an event.
EventBus as Anti-Corruption Layer
The EventBus is not just a messaging system. It's the anti-corruption layer between modules. Each module publishes events that describe what happened in its domain. Other modules subscribe and translate those events into their own domain concepts.
// Wishlist module: publishes domain event
export class WishlistItemAddedEvent extends VendureEvent {
constructor(
public ctx: RequestContext,
public wishlistId: string,
public productVariantId: string,
public customerId: string,
) {
super();
}
}
// Wishlist service: emits event after business logic
@Injectable()
export class WishlistService {
constructor(private eventBus: EventBus) {}
async addItem(ctx: RequestContext, input: AddItemInput): Promise<WishlistItem> {
// Business logic: validate, check duplicates, save
const item = await this.saveItem(ctx, input);
// Publish event: "something happened in my domain"
await this.eventBus.publish(
new WishlistItemAddedEvent(ctx, input.wishlistId, input.productVariantId, ctx.activeUserId)
);
return item;
}
}
// Loyalty module: subscribes and translates to its own domain
@Injectable()
export class LoyaltyEventHandler {
constructor(
private eventBus: EventBus,
private loyaltyService: LoyaltyService,
) {
// Subscribe: translate wishlist event to loyalty concept
this.eventBus.ofType(WishlistItemAddedEvent).subscribe(async event => {
await this.loyaltyService.awardPoints(
event.ctx,
event.customerId,
'wishlist_add', // Loyalty's own concept, not wishlist's
10, // Points amount (loyalty's decision)
);
});
}
}
What Makes This an Anti-Corruption Layer
- The wishlist module doesn't know loyalty exists. It publishes
WishlistItemAddedEventregardless of subscribers. - The loyalty module doesn't import wishlist services. It only depends on the event class.
- The points amount (10) is a loyalty domain decision, not a wishlist concern.
- If loyalty is removed, wishlist continues working unchanged.
- If a new module (analytics) wants to react to wishlist adds, it subscribes to the same event. Zero changes to wishlist.
Module Structure in TypeScript/NestJS
A well-structured module has a clear internal organization:
src/modules/wishlist/
βββ entities/
β βββ wishlist.entity.ts
β βββ wishlist-item.entity.ts
βββ services/
β βββ wishlist.service.ts
βββ resolvers/
β βββ wishlist-shop.resolver.ts
β βββ wishlist-admin.resolver.ts
βββ events/
β βββ wishlist.events.ts
βββ schemas/
β βββ wishlist-shop.schema.ts
β βββ wishlist-admin.schema.ts
βββ types/
β βββ wishlist.types.ts
βββ wishlist.module.ts
Module Registration
// wishlist.module.ts
@Module({
imports: [TypeOrmModule.forFeature([CiWishlist, CiWishlistItem])],
providers: [WishlistService, WishlistShopResolver, WishlistAdminResolver],
exports: [WishlistService], // Only if other modules legitimately need it
})
export class WishlistModule {}
The exports array is the public API of the module. Only export what other modules genuinely need. Most modules should export nothing (communicate via events instead).
What Crosses Module Boundaries
| Crosses Boundary | How |
|---|---|
| Events | Published via EventBus, subscribed by other modules |
| Entity IDs | Passed as primitive values (string/number), not entity references |
| DTOs/interfaces | Shared types for event payloads |
| Does NOT Cross Boundary | Why |
|---|---|
| Service instances | Creates coupling. Use events instead. |
| Entity references | Module A shouldn't query module B's tables. |
| Repository access | Each module owns its own data. |
| Internal state | Private to the module. |
When DDD Is Overkill
DDD concepts add value when the domain is complex and the team is large. For many applications, simpler patterns work better.
| Situation | DDD? | Better Approach |
|---|---|---|
| CRUD application with simple business rules | No | Standard MVC/service-repository pattern |
| 2-3 engineers, one bounded context | No | Well-organized monolith with clear folders |
| Startup MVP (product-market fit unknown) | No | Move fast, refactor later when domain stabilizes |
| Enterprise system with 6+ distinct business domains | Yes | Bounded contexts with clear module boundaries |
| Multiple teams working on the same codebase | Yes | Module ownership aligns with team ownership |
| Complex business rules that vary by context | Yes | Domain models capture the rules explicitly |
The Pragmatic Middle Ground
You don't need aggregates, domain events, and anti-corruption layers for a blog with comments. You DO need module boundaries and event-based communication for a commerce platform with wishlist, reviews, loyalty, cart recovery, and back-in-stock alerts.
The middle ground: organize code into modules with clear boundaries and EventBus communication. Don't implement full DDD patterns unless the domain complexity justifies it. Three similar lines of code is better than a premature abstraction.
Shared Kernel: The Code That Belongs to Nobody
Some code is genuinely shared between modules. Constants, utility functions, base types, and common interfaces. This is the shared kernel.
src/shared/
βββ constants/
β βββ index.ts # TABLE_NAMES, permissions, etc.
βββ types/
β βββ common.types.ts # Shared DTOs, pagination types
βββ utils/
β βββ date.utils.ts # Date formatting, timezone helpers
βββ errors/
βββ common.errors.ts # Base error classes
Rules for the shared kernel:
- Minimal: Only what genuinely needs sharing. If it's used by one module, it belongs in that module.
- Stable: Changes to the shared kernel affect all modules. It should change rarely.
- No business logic: Utilities, types, and constants only. Business rules belong in domain modules.
- Owned by the platform team (or explicitly nobody). If the shared kernel has no owner, it becomes a dumping ground.
Refactoring Toward Boundaries
Most codebases don't start with clean module boundaries. They evolve from a monolith where everything imports everything. Refactoring toward boundaries is a gradual process.
Step 1: Map the Current Dependencies
# Generate dependency graph
npx madge --image dependency-graph.svg src/
# Find the most-imported files (coupling hotspots)
grep -rn "import.*from" src/ --include="*.ts" | awk -F"from " '{print $2}' | sort | uniq -c | sort -rn | head 20
Step 2: Identify Natural Boundaries
Look for clusters of files that change together. If wishlist.service.ts, wishlist.entity.ts, and wishlist.resolver.ts always change in the same PR, they're a natural module.
Step 3: Extract One Module at a Time
Iteration 1: Move wishlist files into src/modules/wishlist/
Iteration 2: Replace direct imports with events
Iteration 3: Move loyalty files into src/modules/loyalty/
Iteration 4: Replace direct imports with events
...
Don't try to extract all modules at once. Extract one, verify it works, then extract the next. Each extraction should be a separate PR with its own tests.
Step 4: Enforce Boundaries
// ESLint rule to prevent cross-module imports
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['*/modules/loyalty/*'],
message: 'Wishlist module cannot import from loyalty. Use EventBus.',
},
],
}],
},
};
Enforce boundaries with linting, not with documentation. Documentation gets ignored. Lint errors block PRs.
Common Pitfalls
-
Starting with DDD instead of refactoring toward it. On a new project, start simple. Add module boundaries when the domain complexity demands it. Don't design aggregates for a CRUD app.
-
Exporting everything. If a module exports all its services, other modules will import them. Export nothing by default. Use events for cross-module communication.
-
Shared database queries across modules. Module A should not write SQL that joins module B's tables. Each module owns its data. If A needs data from B, B exposes it via an event or a public service method.
-
Events with too much data. An event should carry IDs and minimal context, not entire entities. The subscriber fetches what it needs from its own data store.
-
No enforcement. Module boundaries without lint rules are suggestions. Suggestions get violated under deadline pressure. Lint rules block violations.
-
Premature abstraction. Three similar functions in three modules is fine. Extracting a shared abstraction before you understand the pattern creates the wrong abstraction. Wait until the third time.
-
DDD vocabulary without DDD understanding. Naming things "aggregate" and "value object" without understanding the purpose is cargo cult architecture. The purpose is capturing domain rules, not impressing code reviewers.
Key Takeaways
-
DDD is about boundaries, not patterns. Where does one module end and another begin? What does each module own? How do they communicate? Get these right and the codebase stays clean.
-
Events are the anti-corruption layer. The EventBus prevents circular dependencies and keeps modules independent. Emitters don't know about subscribers. Communication is unidirectional.
-
Export nothing by default. Module services are private unless there's a genuine need to export them. Most inter-module communication should go through events.
-
Start simple, refactor toward boundaries. Don't design aggregates on day one. Build the feature, observe the natural boundaries, then extract modules.
-
Enforce with linting, not documentation. ESLint rules that prevent cross-module imports are more effective than wiki pages that describe the module structure.
-
The shared kernel is minimal and stable. Constants, types, and utilities only. No business logic. Changes to the shared kernel affect every module.
We apply these patterns across our custom software projects and Vendure plugin development. If you need help with codebase architecture, talk to our team or request a quote. See also our software engineering guide for our broader engineering principles.
Topics covered
Related Guides
Enterprise Guide to Agentic AI Systems
Technical guide to agentic AI systems in enterprise environments. Learn the architecture, capabilities, and applications of autonomous AI agents.
Read guideAgentic Commerce: How to Let AI Agents Buy Things Safely
How to design governed AI agent-initiated commerce. Policy engines, HITL approval gates, HMAC receipts, idempotency, tenant scoping, and the full Agentic Checkout Protocol.
Read guideThe 9 Places Your AI System Leaks Data (and How to Seal Each One)
A systematic map of every place data leaks in AI systems. Prompts, embeddings, logs, tool calls, agent memory, error messages, cache, fine-tuning data, and agent handoffs.
Read guideReady to build production AI systems?
Our team specializes in building production-ready AI systems. Let's discuss how we can help transform your enterprise with cutting-edge technology.
Start a conversation