Loading learning content...
Every modular system faces a fundamental architectural challenge: Where do you draw the boundaries? Which classes belong together in one module, and which should be separated? What constitutes a meaningful unit of organization?
These questions seem simple, but the answers have profound implications. Draw boundaries too broadly, and you create monolithic packages that defeat the purpose of modularization. Draw them too narrowly, and you create a fragmented landscape where navigation is exhausting and cross-module coordination dominates.
Module boundaries are not just organizational conveniences—they are architectural decisions that encode assumptions about how the system will evolve, how teams will collaborate, and how components will (or won't) be deployed independently.
By the end of this page, you'll understand the principles behind effective module boundaries, learn techniques for identifying natural boundaries in your domain, and develop the judgment to make boundary decisions that serve long-term system health.
A module boundary is the explicit separation between one logical unit of code and another. It defines:
In different languages and ecosystems, module boundaries manifest differently:
| Language/Platform | Primary Mechanism | Visibility Control | Distribution Unit |
|---|---|---|---|
| Java | Packages + Modules (JPMS) | public, package-private, private | JAR files |
| TypeScript/Node | Modules + Packages | export declarations | npm packages |
| C#/.NET | Namespaces + Assemblies | public, internal, private | DLL/NuGet packages |
| Python | Packages + Modules | all, underscore convention | PyPI packages |
| Go | Packages | Capitalization (exported) | Go modules |
The physical and logical distinction:
It's important to distinguish between logical boundaries (how we conceptualize separation) and physical boundaries (how the build system and runtime enforce separation).
Logical boundaries can evolve into physical boundaries as the system matures. A feature that starts as a folder in a monolith can become a separate package, then a separate service. The earlier you establish clear logical boundaries, the easier this evolution becomes.
Well-defined module boundaries don't just organize code—they create options. A well-bounded module can be: • Replaced with an alternative implementation • Extracted into a separate service • Developed by a different team • Tested in isolation • Deployed independently (with proper infrastructure)
Without boundaries, these options don't exist—you have a monolith regardless of your folder structure.
Several established principles guide where to draw module boundaries. These aren't mutually exclusive—they often reinforce each other and should be considered together.
Conway's Law in action:
"Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations." — Melvin Conway
This means your module boundaries will likely mirror your team structure—or vice versa. If you want independent modules, you need independent teams. If you want cross-cutting platforms, you need platform teams. The architecture and organization must align, or one will force the other to change.
Some organizations deliberately structure teams to produce their desired architecture—the 'Inverse Conway Maneuver.' If you want feature-based modules, create feature teams. If you want a powerful core platform, create a platform team. The team structure creates gravitational pull toward the corresponding module structure.
While principles provide guidance, identifying where boundaries naturally exist in your specific domain requires analysis and domain understanding. Here are techniques for discovering boundaries:
1234567891011121314151617181920212223242526
Domain Events from an E-Commerce Event Storming Session:═══════════════════════════════════════════════════════════ Cluster 1: User Management Cluster 2: Product Catalog┌─────────────────────────────────┐ ┌──────────────────────────────┐│ • UserRegistered │ │ • ProductCreated ││ • UserLoggedIn │ │ • ProductUpdated ││ • UserLoggedOut │ │ • ProductPriceChanged ││ • PasswordReset │ │ • ProductCategorized ││ • UserProfileUpdated │ │ • InventoryAdjusted ││ • UserDeactivated │ │ • ProductDiscontinued │└─────────────────────────────────┘ └──────────────────────────────┘ ↓ (few cross-refs) ↓ (few cross-refs) Cluster 3: Order Processing Cluster 4: Payment┌─────────────────────────────────┐ ┌──────────────────────────────┐│ • OrderPlaced │ ←→ │ • PaymentInitiated ││ • OrderConfirmed │ │ • PaymentAuthorized ││ • OrderShipped │ │ • PaymentCaptured ││ • OrderDelivered │ │ • PaymentRefunded ││ • OrderCancelled │ │ • PaymentFailed ││ • OrderReturned │ │ • ChargebackReceived │└─────────────────────────────────┘ └──────────────────────────────┘ Each cluster = A candidate module boundaryCross-cluster references (←→) = Integration points to design carefullyFracture planes:
Think of your codebase as having fracture planes—natural joints where separation is cleanest. These often appear at:
The boundary between modules is defined by the contract they share—the public API that one module exposes and others consume. Well-designed contracts are crucial because they determine how coupled modules become.
Properties of a good module contract:
1234567891011121314151617181920212223242526272829303132333435
// payment-processing/index.ts// The module's PUBLIC CONTRACT - what other modules can depend on // Interfaces (abstractions that hide implementation)export { PaymentService } from './api/PaymentService';export type { PaymentProcessor } from './api/PaymentProcessor'; // Domain types (stable value objects and enums)export { PaymentStatus, PaymentMethod } from './domain/PaymentTypes';export type { PaymentId, PaymentRequest, PaymentResult, RefundRequest, RefundResult,} from './domain/PaymentTypes'; // Events (for event-driven integration)export type { PaymentAuthorized, PaymentCaptured, PaymentFailed, PaymentRefunded,} from './events/PaymentEvents'; // Factory for obtaining the service (DI entry point)export { createPaymentService } from './PaymentServiceFactory'; // Everything else is INTERNAL to the module:// - StripeAdapter, PayPalAdapter (infrastructure implementations)// - PaymentValidator (internal validation logic)// - FraudDetectionService (internal orchestration)// - Database entities and repositories//// These can change freely without affecting consumers.A 'leaky abstraction' occurs when internal implementation details bleed through the contract. Examples: • Exposing database entities instead of domain types • Throwing framework-specific exceptions • Requiring consumers to deal with internal state • Having method signatures that reveal infrastructure (SQL, HTTP)
Leaky abstractions couple consumers to implementation, defeating the boundary's purpose.
Once boundaries are established, modules must communicate. The integration strategy you choose deeply affects coupling and flexibility.
| Strategy | Description | Coupling Level | Best For |
|---|---|---|---|
| Direct Call | Module A directly imports and calls Module B's interface | Moderate | Same process, synchronous operations |
| Dependency Injection | B's implementation is injected into A; A depends on abstraction only | Low | Same process, testability priority |
| Mediator/Message Bus | Modules communicate through a central mediator without knowing each other | Very Low | Many-to-many communication patterns |
| Domain Events | Modules publish events; interested modules subscribe | Very Low | Async workflows, eventual consistency |
| API (REST/gRPC) | Modules communicate over HTTP/RPC as services | Low | Separate deployments, polyglot |
| Shared Database | Modules share a database (anti-pattern) | Very High | Avoid when possible |
1234567891011121314151617181920
// order-processing/CreateOrder.tsimport { PaymentService } from '../payment-processing'; export class CreateOrderUseCase { constructor( private paymentService: PaymentService, ) {} async execute(order: OrderRequest) { // Direct call to payment module const payment = await this.paymentService .authorize(order.paymentDetails); if (!payment.success) { throw new PaymentFailedError(); } // Continue with order creation }}123456789101112131415161718192021222324252627
// order-processing/CreateOrder.tsimport { EventBus } from '../shared/events'; export class CreateOrderUseCase { constructor( private eventBus: EventBus, ) {} async execute(order: OrderRequest) { const orderPlaced = new OrderPlaced(order); // Publish event - we don't know who handles it await this.eventBus.publish(orderPlaced); // Payment module subscribes to OrderPlaced // and handles it asynchronously }} // payment-processing/handlers/OrderPlacedHandler.tsexport class OrderPlacedHandler { async handle(event: OrderPlaced) { await this.paymentService.authorize( event.paymentDetails ); }}If operations must be transactional (all-or-nothing), direct calls or synchronous mediators are appropriate. If eventual consistency is acceptable, events provide looser coupling. Don't force asynchronous patterns where immediate consistency is required—it adds complexity without benefit.
When modules have different domain models, languages, or assumptions, direct integration creates corruption—one module's concepts leak into another, creating confusion and coupling.
The Anti-Corruption Layer (ACL) pattern addresses this by placing a translation layer at the boundary. The ACL speaks both languages—the external module's language on one side and the internal domain language on the other.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// payment-processing/infrastructure/StripeAntiCorruptionLayer.ts // Stripe's types (external, third-party concepts)import Stripe from 'stripe'; // Our domain types (our language)import { PaymentRequest, PaymentResult, PaymentStatus, PaymentMethod } from '../domain/PaymentTypes'; /** * Anti-Corruption Layer: Translates between Stripe's model and our domain model. * Our domain code never sees Stripe types - only this layer does. */export class StripeAntiCorruptionLayer { constructor(private stripe: Stripe) {} /** * Translate our domain's PaymentRequest into Stripe's PaymentIntent */ async processPayment(request: PaymentRequest): Promise<PaymentResult> { // Translate to Stripe's language const stripeIntent = await this.stripe.paymentIntents.create({ amount: request.amount.cents, // Stripe uses cents currency: request.amount.currency.toLowerCase(), payment_method: this.toStripePaymentMethod(request.method), confirm: true, metadata: { orderId: request.orderId.value, }, }); // Translate back to our domain's language return this.toPaymentResult(stripeIntent); } private toStripePaymentMethod(method: PaymentMethod): string { // Map our domain enum to Stripe's string identifiers const mapping: Record<PaymentMethod, string> = { [PaymentMethod.CREDIT_CARD]: 'pm_card', [PaymentMethod.DEBIT_CARD]: 'pm_card', [PaymentMethod.BANK_TRANSFER]: 'pm_bank_transfer', }; return mapping[method]; } private toPaymentResult(intent: Stripe.PaymentIntent): PaymentResult { // Map Stripe's status to our domain enum const statusMapping: Record<string, PaymentStatus> = { 'succeeded': PaymentStatus.CAPTURED, 'requires_capture': PaymentStatus.AUTHORIZED, 'requires_action': PaymentStatus.PENDING, 'canceled': PaymentStatus.CANCELLED, 'failed': PaymentStatus.FAILED, }; return { id: PaymentId.from(intent.id), status: statusMapping[intent.status] ?? PaymentStatus.UNKNOWN, amount: Money.fromCents(intent.amount, intent.currency), processedAt: new Date(intent.created * 1000), }; }} // Domain code uses only our types:// const result = await stripeACL.processPayment(request);// result.status === PaymentStatus.CAPTURED (our enum, not Stripe's string)Anti-Corruption Layers add code—there's no denying that. But this investment pays dividends: • External systems can be swapped without domain changes • Domain code remains pure and testable • External API changes are isolated to the ACL • Team understanding improves (no mixed models)
The overhead is recurring but bounded; the alternative (model pollution) compounds over time.
Defined boundaries are only useful if they're enforced. Without enforcement, boundaries erode over time as developers under deadline pressure take shortcuts that 'just this once' bypass the intended structure. Before long, the carefully designed architecture exists only in outdated documentation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// __tests__/architecture.test.tsimport { Project, SyntaxKind } from 'ts-morph'; describe('Module Boundary Enforcement', () => { const project = new Project({ tsConfigFilePath: './tsconfig.json' }); it('domain modules should not import from infrastructure', () => { const domainFiles = project.getSourceFiles('**/domain/**/*.ts'); const violations: string[] = []; for (const file of domainFiles) { const imports = file.getImportDeclarations(); for (const imp of imports) { const moduleSpecifier = imp.getModuleSpecifierValue(); if (moduleSpecifier.includes('/infrastructure/')) { violations.push( `${file.getFilePath()}: imports from infrastructure (${moduleSpecifier})` ); } } } expect(violations).toEqual([]); }); it('feature modules should not import from other features directly', () => { const features = ['user-management', 'order-processing', 'payment']; const violations: string[] = []; for (const feature of features) { const featureFiles = project.getSourceFiles(`**/${feature}/**/*.ts`); const otherFeatures = features.filter(f => f !== feature); for (const file of featureFiles) { for (const imp of file.getImportDeclarations()) { const spec = imp.getModuleSpecifierValue(); for (const other of otherFeatures) { // Features can only import from shared/ or their own module if (spec.includes(`/${other}/`) && !spec.includes('/shared/')) { violations.push( `${feature} imports from ${other}: ${file.getBaseName()}` ); } } } } } expect(violations).toEqual([]); });});Relying solely on 'the team knows the rules' is a recipe for erosion. Documentation gets outdated, new joiners don't know the history, and deadline pressure creates exceptions that become precedents. Automate enforcement wherever possible—it's not about distrust, it's about sustainability.
Let's consolidate the key principles for defining and maintaining module boundaries:
What's next:
We've covered the 'why' and 'where' of module boundaries. In the final page of this module, we'll explore practical organization strategies—hands-on patterns and conventions for structuring real-world codebases, from startup projects to enterprise systems.
You now understand how to identify and define module boundaries that serve long-term system health. You can apply boundary-drawing principles, design clean contracts, choose appropriate integration strategies, and enforce boundaries through automation. Next, we'll apply all these concepts to practical organization patterns.