Loading learning content...
Ask any seasoned architect what the hardest problem in software design is, and you'll often hear: where to draw the lines. Getting module boundaries wrong is expensive—too granular and you drown in cross-module coordination; too coarse and you're back to a monolithic tangle.
The boundaries you choose today will shape your codebase for years. They determine which changes are easy and which are painful. They influence team structures, testing strategies, and eventual service extraction paths.
This page equips you with the frameworks, heuristics, and practical techniques to draw boundaries that serve your system well—boundaries that are stable enough to rely on but flexible enough to evolve.
By the end of this page, you will understand how to identify natural boundaries using Domain-Driven Design, analyze dependencies to validate your choices, and implement enforcement mechanisms that preserve your architecture over time.
The most effective approach to module boundaries comes from Domain-Driven Design (DDD). Eric Evans' seminal work gives us two key concepts: Bounded Contexts and the Ubiquitous Language.
Bounded Contexts:
A bounded context is a semantic boundary within which a particular model applies. Inside this boundary, terms have precise meanings and the model is internally consistent. Outside the boundary, the same terms might mean something different.
Example: The Word 'Customer'
Each context has a valid, internally consistent model of 'Customer'. Forcing these into a single Customer entity creates a bloated, incoherent model that serves no context well.
Ubiquitous Language:
Within each bounded context, the team develops a precise vocabulary—the ubiquitous language. This language appears in code, documentation, and conversations. When the language changes, the code changes. When unexpected domain concepts emerge in code, it signals a modeling problem.
Translating to Module Boundaries:
Each bounded context becomes a module. The bounded context's explicit interfaces become the module's public API. The ubiquitous language shapes the module's internal structure and naming.
If domain experts from different areas of your business use the same term with different meanings, you've found a context boundary. If they use different terms for the same concept, you might be over-splitting. Listen to how experts actually talk about the domain.
Beyond DDD theory, practical heuristics help identify where boundaries naturally want to exist. These heuristics triangulate toward good boundary placement.
Heuristic 1: Cohesion Analysis
Classes and functions that change together, are tested together, and address the same business capability belong together. High cohesion within a boundary, low coupling across boundaries. This is the modular equivalent of the Single Responsibility Principle.
Heuristic 2: Change Rate Analysis
Examine your version control history. Which files tend to change in the same commits? Which directories evolve together? Clustering by change patterns often reveals implicit boundaries that weren't part of the original design.
Heuristic 3: Team Ownership Patterns
Boundaries should align with teams. If one team owns multiple modules, they should be related. If one module requires coordination across multiple teams, it may be too large or misaligned.
123456789101112131415161718192021222324252627282930
# Find files that frequently change together# This reveals implicit coupling that may indicate module boundaries # Get pairs of files that changed in the same commitgit log --name-only --pretty=format: | \ awk 'NF' | \ sort | uniq -c | sort -rn | head -50 # Find the most coupled file pairsgit log --name-only --pretty=format: | \ grep -v '^$' | \ awk ' NF { files[NR] = $0 } !NF && NR > 1 { for (i in files) { for (j in files) { if (i < j) pairs[files[i] "," files[j]]++ } } delete files } END { for (pair in pairs) print pairs[pair], pair } ' | sort -rn | head -20 # Visualize directory couplinggit log --name-only --pretty=format: | \ awk -F'/' 'NF > 1 { print $1 "/" $2 }' | \ sort | uniq -c | sort -rnHeuristic 4: Data Ownership
Ask: 'Who is the authoritative source for this data?' The answer often reveals module boundaries. If an Order has a total amount, the Order module owns it. If a User has a loyalty tier, the User module—or possibly a separate Loyalty module—owns it.
Data that needs to be consistent across operations often belongs in the same module. Data that can be eventually consistent often belongs in separate modules.
Heuristic 5: Business Capability Mapping
List your business capabilities: managing users, processing orders, handling payments, tracking inventory, generating reports. Each capability is a candidate module. Some capabilities are clearly separate; others may be grouped or split based on team size and complexity.
Technical layers (controllers, services, repositories) are NOT good module boundaries. They create horizontal slices that require coordination for every feature. Good modules are vertical slices—containing everything needed for a business capability from API to data.
Once you've identified where boundaries should exist, you need to implement them correctly. Several patterns help establish and maintain module boundaries.
Pattern 1: Facade Pattern (Public API)
Each module exposes a single facade that defines its public contract. All external access goes through this facade. Internal classes are not directly accessible from outside the module.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// TypeScript - Module Public API // modules/order/api/OrderService.ts - The single entry pointexport interface OrderService { // Commands createOrder(userId: string, items: OrderItemInput[]): Promise<OrderId>; cancelOrder(orderId: OrderId): Promise<void>; // Queries getOrderById(orderId: OrderId): Promise<OrderDTO | null>; getOrdersByUser(userId: string): Promise<OrderDTO[]>;} // modules/order/api/types.ts - Public DTOsexport interface OrderDTO { id: string; userId: string; items: OrderItemDTO[]; status: OrderStatus; total: Money; createdAt: Date;} export interface OrderItemDTO { productId: string; quantity: number; unitPrice: Money; subtotal: Money;} // modules/order/api/events.ts - Published domain eventsexport interface OrderCreatedEvent { type: 'order.created'; orderId: string; userId: string; items: Array<{ productId: string; quantity: number }>; occurredAt: Date;} export interface OrderCancelledEvent { type: 'order.cancelled'; orderId: string; reason: string; occurredAt: Date;} // modules/order/index.ts - Single export pointexport { OrderService } from './api/OrderService';export type { OrderDTO, OrderItemDTO, OrderStatus } from './api/types';export type { OrderCreatedEvent, OrderCancelledEvent } from './api/events'; // INTERNAL CLASSES ARE NOT EXPORTED// These stay hidden inside the module:// - Order (domain entity)// - OrderRepository// - OrderDomainService// - PriceCalculator// - etc.Pattern 2: Anti-Corruption Layer (ACL)
When modules need to communicate, an Anti-Corruption Layer translates between their models. This prevents one module's model from leaking into another, maintaining each module's integrity.
123456789101112131415161718192021222324252627282930313233343536373839
// The Order module needs user information but shouldn't depend// on the User module's internal model // modules/order/internal/adapters/UserAdapter.tsimport { UserService, UserDTO } from '@/modules/user'; // Order module's view of a user - only what Order needsinterface OrderUser { id: string; displayName: string; shippingPreference: 'standard' | 'express';} export class UserAdapter { constructor(private userService: UserService) {} async getOrderUser(userId: string): Promise<OrderUser | null> { const userDto = await this.userService.getUserById(userId); if (!userDto) return null; // Translation layer - map User module's model to Order's needs return { id: userDto.id, displayName: userDto.firstName + ' ' + userDto.lastName, shippingPreference: this.mapShippingPreference(userDto.membershipTier), }; } private mapShippingPreference(tier: string): 'standard' | 'express' { // Order module decides its own mapping logic return tier === 'premium' || tier === 'enterprise' ? 'express' : 'standard'; }} // Now Order module uses OrderUser internally// If User module changes, only UserAdapter needs updating// Order's internal code is protected from changes in User modulePattern 3: Domain Events for Loose Coupling
Rather than modules calling each other directly, publish domain events. Interested modules subscribe to events they care about. This inverts dependencies and enables modules to evolve independently.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// modules/order/internal/services/OrderApplicationService.ts import { EventBus } from '@/shared/events';import { OrderCreatedEvent } from '../api/events'; export class OrderApplicationService { constructor( private orderRepository: OrderRepository, private eventBus: EventBus ) {} async createOrder(command: CreateOrderCommand): Promise<OrderId> { const order = Order.create(command.userId, command.items); await this.orderRepository.save(order); // Publish event - other modules can subscribe await this.eventBus.publish<OrderCreatedEvent>({ type: 'order.created', orderId: order.id.value, userId: command.userId, items: command.items.map(i => ({ productId: i.productId, quantity: i.quantity, })), occurredAt: new Date(), }); return order.id; }} // modules/inventory/internal/handlers/OrderEventHandler.ts import { EventHandler, on } from '@/shared/events';import { OrderCreatedEvent } from '@/modules/order'; export class OrderEventHandler { constructor(private inventoryService: InventoryService) {} @on('order.created') async handleOrderCreated(event: OrderCreatedEvent) { // Inventory module reacts to Order module's event // No direct coupling - Order doesn't know Inventory exists for (const item of event.items) { await this.inventoryService.reserveStock( item.productId, item.quantity ); } }} // modules/notification/internal/handlers/OrderEventHandler.ts export class NotificationOrderEventHandler { @on('order.created') async handleOrderCreated(event: OrderCreatedEvent) { // Notification module also subscribes // Multiple modules can react to the same event await this.sendOrderConfirmationEmail(event.userId, event.orderId); }}In a modular monolith, these events are typically in-process (not over network). The EventBus is a simple observer pattern, not a message queue. This keeps things simple while maintaining loose coupling. If you later extract a module into a service, the event pattern translates naturally to message queues.
Proposed boundaries should be validated through dependency analysis. Poor boundaries manifest as:
Techniques for Validation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// .dependency-cruiser.js - Configuration for JavaScript/TypeScript projects module.exports = { forbidden: [ // No cycles between modules { name: "no-circular-between-modules", severity: "error", from: { path: "^modules/([^/]+)/" }, to: { circular: true, path: "^modules/(?!\1)[^/]+/" } }, // Modules can only access other modules' public APIs { name: "no-internal-access", severity: "error", from: { path: "^modules/([^/]+)/" }, to: { path: "^modules/(?!\1)[^/]+/internal/", pathNot: "^modules/\1/" // Allow access to own internals } }, // No direct database access from outside module { name: "no-cross-module-repository-access", severity: "error", from: { path: "^modules/([^/]+)/" }, to: { path: "^modules/(?!\1)[^/]+/.*[Rr]epository", } }, // Shared kernel should be minimal and not depend on modules { name: "shared-should-not-depend-on-modules", severity: "error", from: { path: "^shared/" }, to: { path: "^modules/" } }, ], options: { doNotFollow: { path: "node_modules" }, tsPreCompilationDeps: true, enhancedResolveOptions: { exportsFields: ["exports"], conditionNames: ["import", "require", "node", "default"], }, }};Visualizing Dependencies:
Generate dependency graphs regularly and review them as part of architecture governance. A healthy modular monolith shows:
If your dependency graph looks like a hairball, your modular structure has eroded. Common symptoms: bidirectional dependencies between modules, shared code depending on modules, every module depending on a 'utils' or 'common' module that keeps growing.
Even with good boundaries, some concerns span multiple modules: logging, authentication, transactions, auditing. These cross-cutting concerns require special handling to avoid creating dependencies that violate module boundaries.
Strategy 1: Shared Kernel
A minimal shared kernel contains types and utilities that all modules need. Keep it extremely minimal—only things that genuinely belong everywhere. Common candidates:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// shared/types/money.ts - Value type used everywhereexport class Money { constructor( public readonly amount: number, public readonly currency: string ) {} add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add different currencies'); } return new Money(this.amount + other.amount, this.currency); } multiply(factor: number): Money { return new Money(this.amount * factor, this.currency); } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }} // shared/events/EventBus.ts - Infrastructure for module communicationexport interface DomainEvent { type: string; occurredAt: Date;} export interface EventBus { publish<T extends DomainEvent>(event: T): Promise<void>; subscribe<T extends DomainEvent>( eventType: string, handler: (event: T) => Promise<void> ): void;} // shared/context/AuthContext.ts - Who is making the requestexport interface AuthContext { userId: string; roles: string[]; permissions: string[]; tenantId?: string;} // shared/index.ts - Controlled exportsexport { Money } from './types/money';export type { DomainEvent, EventBus } from './events/EventBus';export type { AuthContext } from './context/AuthContext'; // KEEP THE SHARED KERNEL SMALL// If in doubt, put it in a module, not sharedStrategy 2: Aspect-Oriented Approaches
For concerns like logging and metrics, use aspects or middleware that wrap module boundaries without requiring module code to know about them:
Strategy 3: Infrastructure Layer
Some cross-cutting concerns belong in an infrastructure layer that modules consume but don't depend on directly:
Modules depend on interfaces; infrastructure provides implementations.
Shared code has gravity—it attracts more code. Once a 'utils' module exists, developers add to it rather than finding the right home. Fight this by requiring explicit justification for any addition to shared code and by splitting shared concerns into specific, named packages.
Initial boundary choices are rarely perfect. As the domain evolves and understanding deepens, boundaries need adjustment. The key is making boundary changes incrementally and safely.
When to Refactor Boundaries:
Refactoring Strategies:
1. Bubble Context (Splitting)
When a module grows too large, extract a new module:
2. Merge Modules (Combining)
When modules are too granular:
3. Strangler Pattern for Boundaries
For large-scale boundary changes:
This is a key advantage of the modular monolith. Refactoring module boundaries is a refactoring—supported by IDE tools, covered by existing tests, and validated by the compiler. Refactoring service boundaries requires API versioning, deployment coordination, and often data migration. Get the boundaries right in the monolith before extracting.
Module boundaries are the fundamental structure of a modular monolith. Getting them right enables team autonomy, maintainable code, and future flexibility. Let's consolidate the key principles:
What's Next:
With boundaries defined, the next page explores Preparing for Extraction—how to design your modular monolith so that modules can be extracted into services when the time is right. We'll cover data separation strategies, interface design for network boundaries, and the incremental path from module to service.
You now understand how to identify, implement, validate, and refactor module boundaries. Good boundaries enable team autonomy within a single codebase. Next, we'll prepare these boundaries for potential service extraction.