Modular Architecture: How to Divide a Growing Application
A guide to modular application architecture: modular monoliths, coupling metrics, boundary enforcement, database isolation, and cross-module communication.

Modular Architecture: How to Divide a Growing Application
Every successful software system eventually faces the challenge of its own scale. What begins as a clean, manageable codebase frequently deteriorates into an entangled tangle of dependencies, circular imports, and shared database tables. When teams encounter this friction, the prevailing industry reflex is often to jump straight to microservices.
Yet distributing an unstructured application across remote network boundaries rarely solves underlying coupling problems. Instead, it frequently transforms a chaotic in-process monolith into a fragile distributed monolith—introducing network latency, serialization overhead, distributed transaction failures, and operational headaches without improving modularity.
The solution to scaling software complexity is not premature network distribution; it is disciplined modular architecture. By structuring a system as an in-process Modular Monolith (Modulith)—or by designing distinct, encapsulated modules with explicit boundaries before network extraction—engineering teams preserve high developer velocity, in-memory execution speed, and transactional safety while preventing architectural decay.
This article examines the structural mechanics of modular application design: from information hiding and package coupling metrics to database schema isolation, cross-module communication topologies, and practical TypeScript enforcement.
The Monolith vs. Microservices Fallacy
In software architecture discussions, monoliths and microservices are frequently presented as a binary choice: either a single tangled codebase or dozens of independently deployed network services. This is a false dichotomy that conflates deployment topology with domain modularity.
+-------------------------------------------------------------------------------+
| MODULARITY SPECTRUM |
+------------------------------------+------------------------------------------+
| IN-PROCESS DEPLOYMENTS | NETWORK-DISTRIBUTED DEPLOYMENTS |
+------------------------------------+------------------------------------------+
| Big Ball of Mud | Distributed Monolith |
| (Unstructured Monolith) | (Microservices with tight sync coupling) |
| - Uncontrolled cross-imports | - Cascading HTTP/gRPC call chains |
| - Unrestricted SQL joins | - Distributed transaction failures |
| - Fast deployment, fragile code | - Lock-step synchronized deployments |
+------------------------------------+------------------------------------------+
| Modular Monolith (Modulith) | Autonomous Microservices |
| (Strict In-Process Partitioning) | (Isolated Network Services) |
| - Strictly enforced public APIs | - Independent infrastructure & databases |
| - Isolated database schemas | - Event-driven choreography |
| - In-memory zero-latency calls | - High operational & network overhead |
+------------------------------------+------------------------------------------+
The Distributed Monolith Anti-Pattern
When an engineering organization splits an unmodular monolith into microservices without first establishing clear domain boundaries, they almost invariably create a distributed monolith. This anti-pattern exhibits the worst operational characteristics of both worlds:
- Synchronous RPC Cascades & Latency Penalties: When Service A calls Service B over HTTP/gRPC, which synchronously queries Service C, D, and E, the tail latency of the initial request is governed by the slowest remote network hop. A single degradation or transient timeout ripples backward through the entire system.
- Distributed Transaction Complexities: In an in-process application, an ACID transaction guarantees atomicity across multiple table updates. In a microservice ecosystem, achieving atomicity across distributed databases requires Two-Phase Commit (2PC) or the Saga pattern. Two-Phase Commit introduces blocking coordinator bottlenecks and susceptibility to network partitions, while Sagas require complex compensating transactions and asynchronous eventual consistency handling.
- Lock-Step Deployments: If deploying a new feature in the Order Service requires deploying coordinated changes to the Catalog Service, Payment Service, and Notification Service simultaneously, the services are not autonomous. You pay the operational cost of continuous delivery pipelines for microservices without enjoying independent deployability.
- Network Partition Vulnerabilities: Distributed systems are fundamentally bound by the CAP theorem. Network splits, DNS resolution hiccups, and socket exhaustion inevitably cause partial failures that must be mitigated with circuit breakers, bulkhead pools, and exponential backoff retry policies.
The Modular Monolith (Modulith) Alternative
A Modular Monolith packages distinct, autonomous business domains into a single deployable artifact while enforcing strict architectural barriers between them.
Within a modular monolith:
- Modules communicate in-process through strongly typed public interfaces or in-memory domain events.
- Data access is strictly partitioned: no module can directly query or join another module's internal database tables.
- Call overhead is measured in nanoseconds (direct function invocation in memory) rather than milliseconds of serialization, TLS negotiation, and network transmission.
- Testing is straightforward: unit and integration tests execute in-process without spinning up Docker Compose environments or mocking distributed RPC endpoints.
Objective Criteria for Microservice Extraction
Extracting a module from a modular monolith into an independent microservice should be driven by concrete operational requirements rather than architectural fashion. The primary valid criteria for extraction include:
- Heterogeneous Hardware Scaling Vectors: A specific module has resource demands that diverge sharply from the rest of the application. For instance, an image-processing or machine-learning inference module requiring dedicated GPUs, or a real-time WebSocket connection manager requiring specialized memory configurations, should not force the entire application to run on costly, non-standard compute instances.
- Regulatory and Compliance Isolation: PCI-DSS cardholder data environments (CDE) or HIPAA health-record processing boundaries often demand physical network segmentation and strict access controls. Isolating compliant subsystems into independent services minimizes the scope and expense of external compliance audits.
- Organizational Scaling (Conway's Law): When engineering organizations grow beyond dozens of engineers across multiple autonomous squads, coordinating releases within a single repository and CI pipeline can become a bottleneck. If teams have genuinely disjoint release cadences and clear domain ownership, independent network services allow them to deploy without centralized coordination.
| Dimension | Traditional Monolith | Distributed Monolith | Modular Monolith | Autonomous Microservices | | :--- | :--- | :--- | :--- | :--- | | Deployment Unit | Single deployable binary | Multiple deployable units | Single deployable binary | Multiple deployable units | | Communication | Direct memory calls | Synchronous HTTP / gRPC | Direct memory / In-process events | Asynchronous messaging / gRPC | | Call Latency | Nanoseconds () | Milliseconds () | Nanoseconds () | Milliseconds () | | Data Boundary | Shared tables, open joins | Fractured DBs with sync joins | Isolated schemas, no joins | Isolated DB engines per service | | Transaction Model | ACID local transactions | Attempted 2PC / Broken ACID | ACID local transactions | Sagas / Eventual consistency | | Failure Domain | Entire process | Cascading systemic failure | Entire process | Isolated to failing service | | DevOps Overhead | Minimal | Extreme | Minimal | High (k8s, mesh, tracing) |
Foundational Module Metrics & Design Theory
Effective modularity is not achieved by simply organizing files into folders. It requires understanding the formal engineering principles that govern cohesion, coupling, and information encapsulation.
1. Information Hiding (David Parnas, 1972)
In his seminal paper "On the Criteria to Be Used in Decomposing Systems into Modules", David Parnas demonstrated that modules should not be divided along procedural flowchart steps (e.g., input, process, output). Instead, a module should be characterized by its knowledge of a design decision which it hides from all others.
A well-designed module encapsulates:
- Volatile Storage Details: The physical database schema, SQL queries, indexing choices, and ORM mappings.
- Internal Domain Logic: Complex calculation algorithms, business validation state machines, and private helper classes.
- Third-Party Integrations: External API SDKs, message queue protocols, and vendor-specific data structures.
The public interface of the module reveals only immutable Data Transfer Objects (DTOs) and high-level behavioral contracts. If an internal database table changes from PostgreSQL to Redis or is normalized across three tables, external consuming modules require zero code modifications.
2. Deep vs. Shallow Modules (John Ousterhout)
In A Philosophy of Software Design, John Ousterhout introduced the concept of module depth, defining the relationship between the complexity of a module's interface and the functionality it provides:
DEEP MODULE SHALLOW MODULE
+--------------------+ +--------------------+
| Minimal Interface | | Wide Interface |
+--------------------+ +--------------------+
| | | |
| Substantial | | Trivial Logic |
| Internal | | (Thin Wrapper) |
| Implementation | +--------------------+
| Complexity |
| & Logic | High cost, low value
| |
+--------------------+
Low cost, high value
- Deep Modules: Provide powerful, sophisticated functionality behind a clean, simple, and minimal interface. Examples include standard file I/O libraries (e.g., POSIX
open,read,write,close, which conceal file systems, disk caches, hardware block drivers, and buffer allocations) or an order processing module whose singleplaceOrderinterface method coordinates inventory checks, pricing calculations, fraud validation, and transaction persistence. - Shallow Modules: Feature large, wide interfaces relative to the small amount of logic they implement. Pass-through services, 1:1 entity-to-controller wrappers, and classes consisting almost entirely of trivial getters and setters are shallow. They impose cognitive overhead on developers without providing real encapsulation benefits.
3. Robert C. Martin's Package Coupling Metrics
In his package design principles, Robert C. Martin defined quantitative metrics to measure stability, abstraction, and structural balance in software packages.
Afferent Coupling () and Efferent Coupling ()
- Afferent Coupling (): The number of external classes or modules that depend on types inside this module (incoming dependencies). A high indicates that the module is heavily depended upon and therefore bears significant responsibility.
- Efferent Coupling (): The number of external classes or modules that types inside this module depend upon (outgoing dependencies). A high indicates that the module relies on many external components.
Instability Metric ()
The instability of a module is the ratio of efferent coupling to total coupling:
- (Maximally Stable): . The module has no outgoing dependencies; other modules depend on it. It is difficult to change because alterations can cause cascading ripple effects across all consumers. Core domain entities and shared foundational contracts should target low instability.
- (Maximally Unstable / Flexible): . The module depends on other modules, but nothing depends on it. It can be easily rewritten or refactored without impacting any other part of the system. High-level orchestrators and UI adapters typically have high instability.
According to the Stable Dependencies Principle (SDP), dependencies must run in the direction of stability: a module should only depend on modules that are more stable (lower ) than itself.
Abstractness Metric ()
The abstractness of a module is the ratio of its abstract classes and interfaces to its total types:
where is the count of abstract types and interfaces, and is the total count of classes and types ().
- : The module is completely concrete (contains only concrete implementations).
- : The module is completely abstract (contains only interfaces or abstract contracts).
According to the Stable Abstractions Principle (SAP), a module should be as abstract as it is stable. If a module has low instability (), it must have high abstractness () so that it can be extended without modifying its source code.
Distance from the Main Sequence ()
The Main Sequence represents the optimal balance where abstractness and instability sum to 1:
The normalized distance of a module from this ideal balance is calculated as:
Abstractness (A)
1.0 ▲ Zone of
│ Uselessness
│ \
│ \
│ \ Main Sequence (A + I = 1)
│ \
│ \
│ \
│ \
│ \ Zone of Pain
0.0 +───────────────▼───────────────► Instability (I)
0.0 1.0
- The Zone of Pain (): Highly concrete, highly stable. The module has many dependents, yet exposes concrete implementations rather than interfaces. It is rigid, fragile, and difficult to extend without breaking callers (e.g., an unabstracted database entity shared across an entire system).
- The Zone of Uselessness (): Highly abstract, highly unstable. The module contains extensive abstract interfaces and layers of indirection, yet has zero incoming dependents. It represents dead abstraction and gratuitous complexity.
- The Main Sequence (): Balanced design. Stable modules are appropriately abstract, while concrete modules are free from incoming dependencies.
Boundary Enforcement & Data Isolation
A clean modular architecture cannot survive on developer goodwill alone. Over time, delivery pressures inevitably lead to shortcut imports, direct schema queries, and architectural erosion unless boundaries are enforced through tooling and database design.
1. Structural Encapsulation: Public API vs. Encapsulated Internals
Each domain module in the codebase should have a strict file structure that separates its public contract from its private internals:
src/modules/catalog/
├── index.ts <-- Sole public entry point (re-exports contracts only)
├── contracts/
│ ├── catalog-item.dto.ts <-- Immutable public DTOs
│ ├── catalog.port.ts <-- Public interface ports
│ └── events.ts <-- Public domain event definitions
└── internal/ <-- STRICTLY PRIVATE (encapsulated)
├── domain/ <-- Aggregate roots and entities
├── infrastructure/ <-- Repositories, SQL queries, ORM models
└── services/ <-- Internal domain service logic
2. Static Boundary Enforcement
Boundary violations can be caught at compile time using automated tooling:
- ESLint Boundaries: Tools like
eslint-plugin-boundariesor ESLint'simport/no-restricted-pathsrule can prevent files insrc/modules/orders/**from importing anything insrc/modules/catalog/**exceptsrc/modules/catalog/index.ts. Any attempt to import fromcatalog/internal/**triggers a linting error. - TypeScript Project References: Splitting modules into distinct TypeScript projects with their own
tsconfig.jsonfiles enforces strict compilation boundaries. Combined with the"composite": trueflag and explicit packageexportsin a monorepo workspace (e.g., Nx or Turborepo), private files cannot be resolved by external packages.
// Example ESLint restricted-paths boundary rule in eslint.config.mjs
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "src/modules/orders",
"from": "src/modules/catalog",
"except": ["src/modules/catalog/index.ts"],
"message": "Orders module cannot import Catalog internals. Use the public API in index.ts."
}
]
}
]
}
}
3. Database Isolation in Modular Systems
The single most common cause of architectural degradation in monoliths is database sharing. When any service can query any table via cross-module SQL joins, modularity is completely illusory.
-- CATASTROPHIC ANTI-PATTERN: Cross-Module SQL Join
-- This creates invisible, un-refactorable coupling between Orders and Catalog schemas.
SELECT
o.id AS order_id,
o.customer_id,
p.name AS product_name,
p.supplier_cost -- Leaking Catalog internal pricing into Orders
FROM orders.orders o
JOIN catalog.products p ON o.product_id = p.id;
The Cardinal Rule of Modular Data
No module may ever execute a direct SQL query or join against another module's database tables.
Each module must maintain absolute authority over its data:
- Logical Schema Separation: Within a shared relational database (e.g., PostgreSQL), each module owns an isolated schema:
CREATE SCHEMA catalog; CREATE SCHEMA orders; CREATE SCHEMA payments; - Dedicated Connection Credentials: In high-assurance architectures, separate database users are assigned to each schema. The
orders_userhasALL PRIVILEGESonorders.*, but zero read or write access tocatalog.*. - Handling Cross-Domain Queries: When a consumer needs data from multiple domains (e.g., displaying an order history page showing current product names and thumbnails):
- In-Process Composition: The UI facade calls
OrderModule.getOrder(id)andCatalogModule.getItem(productId)synchronously in memory, assembling the composite view model. - Read Projections (CQRS): The Order module subscribes to
ProductUpdateddomain events and materializes a read-only local projection containing only the necessary denormalized product attributes (product_id,name,sku).
- In-Process Composition: The UI facade calls
Cross-Module Communication Topologies
Within a modular application, modules must interact without creating circular dependencies or brittle temporal coupling. Three primary communication topologies exist, each with distinct trade-offs.
TOPOLOGY 1: IN-PROCESS SYNCHRONOUS PORT
+---------------+ +----------------------+
| OrderModule | ────────────> | ICatalogModule (Port)|
+---------------+ Calls API +----------------------+
│ Implemented by
▼
+----------------------+
| CatalogModuleImpl |
+----------------------+
TOPOLOGY 2: IN-PROCESS ASYNCHRONOUS DOMAIN EVENTS
+---------------+ +----------------------+
| CatalogModule | ────────────> | Typed Event Bus |
+---------------+ Publishes +----------------------+
│ Dispatches
▼
+----------------------+
| OrderModule Listener |
+----------------------+
TOPOLOGY 3: OUT-OF-PROCESS TRANSACTIONAL OUTBOX
+------------------------------------+ +--------------------+
| Local DB Transaction | | Outbox Poller / CDC|
| 1. Write Order Table | ───────> | 1. Read Outbox row |
| 2. Write Outbox Table (atomic) | | 2. Publish to Kafka|
+------------------------------------+ +--------------------+
1. In-Process Synchronous Contracts (Interface Ports)
The consuming module calls an interface port exported by the provider module.
- Execution: Direct in-memory function call. Execution is immediate, synchronous, and non-blocking if asynchronous
Promiseabstractions are handled properly. - Coupling: The caller depends on the provider's interface (). However, it depends on an abstraction, not a concrete class.
- Best For: Direct queries where current, strongly consistent state is required before proceeding (e.g., checking available stock immediately before reserving an order).
2. In-Process Asynchronous Domain Events
Modules publish domain events to an in-memory typed event bus.
- Execution: Decoupled publisher-subscriber interaction. The publisher emits an event and does not know which modules listen to it.
- Coupling: Eliminates compile-time efferent coupling from the producer. The producer has regarding the consumer. Subscribers depend only on the event schema.
- Best For: State changes that trigger side effects across multiple domains (e.g.,
OrderPlacedtriggering inventory deduction, confirmation emails, and analytics logging).
3. Out-of-Process Bridge: The Transactional Outbox Pattern
When an in-process modular monolith must communicate with external third-party services or distributed message queues (e.g., Apache Kafka, RabbitMQ, Amazon SQS), relying on dual-writes introduces the dual-write hazard:
// DANGEROUS: The Dual-Write Problem
await db.orders.create(orderData); // Step 1: Commits to database
await messageBroker.publish(orderEvent); // Step 2: What if this fails or network crashes?
If the database commit succeeds but the message broker call fails, downstream consumers never learn of the event. Conversely, if the message is published before the transaction commits, consumers may process an event for a transaction that rolls back.
The Transactional Outbox Pattern solves this problem atomically:
- When modifying business state, the module inserts the event payload into an
outboxtable as part of the exact same local database transaction. - Because both operations occur in a single ACID transaction, either both succeed or both roll back.
- A background process (such as a Change Data Capture [CDC] pipeline using Debezium or an asynchronous polling worker) reads unpublished records from the outbox table, publishes them to the message broker, and marks them as processed upon confirmation.
- Consumers implement idempotent message handling to safely absorb duplicate deliveries.
| Communication Topology | In-Process Sync Port | In-Process Domain Events | Transactional Outbox (External) | | :--- | :--- | :--- | :--- | | Call Latency | (in-memory) | (in-memory dispatch) | Asynchronous () | | Coupling Level | Low (Interface contract) | Very Low (Event schema only) | Loosely coupled (Network broker) | | Consistency | Immediate / Strong | Eventual / Local In-Memory | Eventual Consistency | | Failure Scope | In-process exception | Caught per event listener | Guaranteed delivery via Outbox log | | Complexity | Minimal | Low | Medium to High |
Clean TypeScript Implementation
Below is a self-contained, type-safe TypeScript implementation demonstrating two business modules (CatalogModule and OrderModule) communicating via both public interface ports and an in-process typed domain event bus.
Notice how:
- Internal repositories and volatile entities are kept private to each module.
- The
OrderModulenever accesses theCatalogModulerepository or database schema directly. - The
OrderModulemaintains a local price projection by subscribing toProductPriceChangedEvent.
// ============================================================================
// SHARED KERNEL: In-Process Domain Event Bus
// ============================================================================
export interface DomainEvent<TName extends string = string, TPayload = unknown> {
readonly eventName: TName;
readonly occurredAt: Date;
readonly payload: TPayload;
}
export type EventHandler<E extends DomainEvent> = (event: E) => Promise<void> | void;
export interface IEventBus {
publish<E extends DomainEvent>(event: E): Promise<void>;
subscribe<E extends DomainEvent>(eventName: E["eventName"], handler: EventHandler<E>): () => void;
}
export class InMemoryEventBus implements IEventBus {
private handlers = new Map<string, Set<EventHandler<any>>>();
async publish<E extends DomainEvent>(event: E): Promise<void> {
const registered = this.handlers.get(event.eventName);
if (!registered || registered.size === 0) return;
// Dispatch to all registered subscribers asynchronously
await Promise.allSettled(
Array.from(registered).map((handler) =>
Promise.resolve(handler(event)).catch((err) => {
console.error(`[EventBus] Handler failed for ${event.eventName}:`, err);
})
)
);
}
subscribe<E extends DomainEvent>(eventName: E["eventName"], handler: EventHandler<E>): () => void {
if (!this.handlers.has(eventName)) {
this.handlers.set(eventName, new Set());
}
const set = this.handlers.get(eventName)!;
set.add(handler as EventHandler<any>);
return () => {
set.delete(handler as EventHandler<any>);
};
}
}
// ============================================================================
// MODULE 1: Catalog Module (Stable, High Ca, Low Ce)
// ============================================================================
// --- Public Interface Contracts (Exported in index.ts) ---
export interface CatalogItemDto {
readonly id: string;
readonly sku: string;
readonly name: string;
readonly priceCents: number;
}
export interface ProductPriceChangedPayload {
readonly productId: string;
readonly previousPriceCents: number;
readonly newPriceCents: number;
}
export type ProductPriceChangedEvent = DomainEvent<
"catalog.product_price_changed",
ProductPriceChangedPayload
>;
export interface ICatalogModule {
getItemById(id: string): Promise<CatalogItemDto | null>;
checkStock(id: string, quantity: number): Promise<boolean>;
updatePrice(id: string, newPriceCents: number): Promise<void>;
}
// --- Encapsulated Internals (Strictly private to CatalogModule) ---
interface InternalProductEntity {
id: string;
sku: string;
name: string;
priceCents: number;
stockCount: number;
supplierCostCents: number; // Information Hiding: Private vendor pricing
warehouseAisleLocation: string; // Information Hiding: Physical warehouse detail
}
class InternalCatalogRepository {
private products = new Map<string, InternalProductEntity>([
[
"prod-101",
{
id: "prod-101",
sku: "SKU-DATABASE-BOOK",
name: "Designing Data-Intensive Applications",
priceCents: 4500,
stockCount: 15,
supplierCostCents: 2200,
warehouseAisleLocation: "Zone-B-Rack-4",
},
],
]);
async findById(id: string): Promise<InternalProductEntity | null> {
return this.products.get(id) ?? null;
}
async save(entity: InternalProductEntity): Promise<void> {
this.products.set(entity.id, { ...entity });
}
}
export class CatalogModuleImpl implements ICatalogModule {
constructor(
private readonly repo: InternalCatalogRepository,
private readonly eventBus: IEventBus
) {}
async getItemById(id: string): Promise<CatalogItemDto | null> {
const item = await this.repo.findById(id);
if (!item) return null;
// Strip internal supplier secrets and physical warehouse locations
return {
id: item.id,
sku: item.sku,
name: item.name,
priceCents: item.priceCents,
};
}
async checkStock(id: string, quantity: number): Promise<boolean> {
const item = await this.repo.findById(id);
return item !== null && item.stockCount >= quantity;
}
async updatePrice(id: string, newPriceCents: number): Promise<void> {
const item = await this.repo.findById(id);
if (!item) throw new Error(`Product ${id} not found`);
const previousPriceCents = item.priceCents;
item.priceCents = newPriceCents;
await this.repo.save(item);
// Emit domain event for downstream projections
await this.eventBus.publish<ProductPriceChangedEvent>({
eventName: "catalog.product_price_changed",
occurredAt: new Date(),
payload: {
productId: item.id,
previousPriceCents,
newPriceCents,
},
});
}
}
export function createCatalogModule(eventBus: IEventBus): ICatalogModule {
const repo = new InternalCatalogRepository();
return new CatalogModuleImpl(repo, eventBus);
}
// ============================================================================
// MODULE 2: Order Module (Flexible, Low Ca, High Ce)
// ============================================================================
// --- Public Interface Contracts (Exported in index.ts) ---
export interface OrderItemInput {
readonly productId: string;
readonly quantity: number;
}
export interface PlaceOrderInput {
readonly customerId: string;
readonly items: readonly OrderItemInput[];
}
export interface OrderResultDto {
readonly orderId: string;
readonly totalCents: number;
readonly status: "CONFIRMED" | "REJECTED";
readonly failureReason?: string;
}
export type OrderPlacedEvent = DomainEvent<
"orders.order_placed",
{ readonly orderId: string; readonly totalCents: number }
>;
export interface IOrderModule {
placeOrder(input: PlaceOrderInput): Promise<OrderResultDto>;
}
// --- Encapsulated Internals (Strictly private to OrderModule) ---
interface InternalOrderRecord {
id: string;
customerId: string;
totalCents: number;
status: "CONFIRMED" | "REJECTED";
createdAt: Date;
}
export class OrderModuleImpl implements IOrderModule {
private orders = new Map<string, InternalOrderRecord>();
// Local projection materialized from Domain Events
private priceProjectionCache = new Map<string, number>();
constructor(
private readonly catalog: ICatalogModule, // Injects interface port, not implementation
private readonly eventBus: IEventBus
) {
// Invalidate or update local pricing projection upon Catalog events
this.eventBus.subscribe<ProductPriceChangedEvent>(
"catalog.product_price_changed",
(event) => {
this.priceProjectionCache.set(event.payload.productId, event.payload.newPriceCents);
}
);
}
async placeOrder(input: PlaceOrderInput): Promise<OrderResultDto> {
if (input.items.length === 0) {
return { orderId: "", totalCents: 0, status: "REJECTED", failureReason: "Cart is empty" };
}
let calculatedTotalCents = 0;
// Validate stock and price via CatalogModule public contract
for (const item of input.items) {
const hasStock = await this.catalog.checkStock(item.productId, item.quantity);
if (!hasStock) {
return {
orderId: "",
totalCents: 0,
status: "REJECTED",
failureReason: `Insufficient stock for product ${item.productId}`,
};
}
const catalogItem = await this.catalog.getItemById(item.productId);
if (!catalogItem) {
return {
orderId: "",
totalCents: 0,
status: "REJECTED",
failureReason: `Product ${item.productId} not found in catalog`,
};
}
calculatedTotalCents += catalogItem.priceCents * item.quantity;
}
const orderId = `ord-${Date.now()}`;
this.orders.set(orderId, {
id: orderId,
customerId: input.customerId,
totalCents: calculatedTotalCents,
status: "CONFIRMED",
createdAt: new Date(),
});
// Notify other domains asynchronously
await this.eventBus.publish<OrderPlacedEvent>({
eventName: "orders.order_placed",
occurredAt: new Date(),
payload: { orderId, totalCents: calculatedTotalCents },
});
return {
orderId,
totalCents: calculatedTotalCents,
status: "CONFIRMED",
};
}
}
export function createOrderModule(catalog: ICatalogModule, eventBus: IEventBus): IOrderModule {
return new OrderModuleImpl(catalog, eventBus);
}
Mathematical Coupling Evaluation of the Implementation
Let us apply Robert C. Martin's package metrics to the two modules in our TypeScript architecture:
1. Catalog Module Analysis
- Afferent Coupling (): The
OrderModuledepends uponCatalogModuleviaICatalogModuleandProductPriceChangedEvent. Therefore, . - Efferent Coupling ():
CatalogModuledepends only on the shared event bus abstraction (IEventBus). It depends on zero domain modules. Therefore, (relative to domain packages). - Instability ():
- Abstractness ():
The public contract exposes 3 abstract types (
CatalogItemDto,ProductPriceChangedEvent,ICatalogModule) out of 4 total exported types (including the factory functioncreateCatalogModule). - Distance from Main Sequence ():
Because ,
CatalogModulesits close to the Main Sequence, successfully avoiding the Zone of Pain despite its high stability.
2. Order Module Analysis
- Afferent Coupling (): No other domain module currently depends on
OrderModule. Therefore, . - Efferent Coupling ():
OrderModuledepends onICatalogModuleandIEventBus. Therefore, . - Instability ():
- Distance from Main Sequence ():
Because , modifications to
OrderModulecan be made freely without risking cascading compile errors or runtime breakages in other subsystems.
Practical Production Migration Strategy
Decomposing a monolith or dividing a rapidly expanding codebase does not require a high-risk multi-month rewrite. Instead, adopt an incremental strangler modularization workflow:
Phase 1: Visual Partitioning ──> Phase 2: Contract Creation ──> Phase 3: Data Isolation
(Move files to module folders) (Introduce interfaces & DTOs) (Break joins, separate schemas)
- Step 1: Visual & Directory Partitioning: Group existing services, controllers, and models into cohesive domain directories (e.g.,
src/modules/billing,src/modules/inventory). At this stage, code still runs in the same process and accesses the same database. - Step 2: Introduce Public Contracts: Create an
index.tsfile in each module directory. Explicitly export only the minimal set of public interfaces, DTOs, and facade functions. Move all other files intointernal/. - Step 3: Enable Lint Boundary Rules: Configure ESLint's
import/no-restricted-pathsto prohibit cross-module imports intointernal/folders. This freezes architectural decay immediately. - Step 4: Eradicate Cross-Module SQL Joins: Audit all SQL queries and ORM relationships. Replace cross-module table joins with in-process facade calls or domain event projections.
- Step 5: Enforce Logical Schema Separation: Migrate database tables into dedicated PostgreSQL schemas (
catalog.*,orders.*). Revoke cross-schema permissions for application users.
Architectural Verification Checklist
Before considering a modular application architecture production-ready, verify your implementation against this engineering checklist:
Boundary & Dependency Discipline
- [ ] Single Entry Point: Does every domain module expose a single
index.tsfile containing exclusively public contracts, DTOs, and event signatures? - [ ] Zero Deep Imports: Is automated linting configured to block imports targeting module
internal/directories? - [ ] No Circular Module Dependencies: Do module dependencies form a Directed Acyclic Graph (DAG)? If Module A depends on Module B, does Module B have zero efferent coupling back to Module A?
- [ ] Stable Dependencies Principle: Do unstable modules () depend on stable modules () rather than the reverse?
Data & Storage Isolation
- [ ] No Cross-Module Joins: Are all direct SQL
JOINstatements across different domain tables completely eliminated? - [ ] Logical Schema Segmentation: Are module tables partitioned into distinct schemas (e.g.,
orders.*,billing.*) within the database? - [ ] Read Projections for Aggregations: Are read-heavy composite views assembled via in-process facades or asynchronous CQRS event projections rather than shared queries?
Cross-Module Communication
- [ ] Information Hiding: Do module interfaces return immutable DTOs rather than exposing internal ORM models or database entities?
- [ ] Asynchronous Decoupling: Are secondary side effects (notifications, analytics, audit logs) triggered via domain events rather than inline procedural calls?
- [ ] Transactional Outbox for External Systems: If events are broadcast to external message brokers (Kafka, RabbitMQ), is the Transactional Outbox pattern implemented to prevent dual-write inconsistencies?
Conclusion
The architecture of a growing application determines its longevity. The choice between maintainability and chaos is not dictated by whether a system runs in a single process or across a fleet of Kubernetes pods. It is dictated by discipline around boundaries, encapsulation, and coupling.
By implementing a Modular Monolith:
- You eliminate the devastating failure modes of the distributed monolith.
- You gain the zero-latency performance and developer velocity of in-process execution.
- You protect the application against architectural erosion using information hiding and package coupling metrics.
- You maintain a clean, frictionless pathway to extract independent microservices in the future—if and when genuine operational scale demands it.
Architecture is the art of postponing decisions until they are necessary. A well-designed modular architecture gives your team the agility to build quickly today, while preserving the freedom to scale seamlessly tomorrow.


