Monolith vs Modular Monolith vs Microservices

An architectural guide comparing traditional monoliths, modular monoliths, and microservices: boundaries, consistency, Conway's law, and migration.

September 26, 202516 min readOpenStair Engineering
Architectural comparison between Monolith, Modular Monolith, and Microservices topologies

Monolith vs Modular Monolith vs Microservices

Discussions surrounding backend system design often present a false dichotomy: deploy a single monolithic application or decompose your system into dozens of independent microservices. In practice, system architecture exists along a structural continuum. Between the extremes of a single monolithic deployment and a sprawling distributed microservices mesh lies the modular monolith—an architecture that delivers clear domain separation and team boundaries while avoiding the operational, financial, and cognitive taxes of distributed computing.

Choosing an architectural style is rarely a matter of picking the "modern" solution over a "legacy" one. Every topology trades one set of engineering problems for another. Monoliths trade independent deployability for operational simplicity and transactional consistency. Microservices trade operational simplicity for team autonomy and targeted horizontal scalability. Modular monoliths strike a pragmatic middle ground, offering clean architectural boundaries within a single deployable unit.

This guide evaluates traditional monoliths, modular monoliths, and microservices across core architectural dimensions: process topologies, database boundaries, distributed systems failure modes, Conway's Law, and migration strategies.


1. The Architectural Continuum: Three Structural Topologies

Architectural topologies differ fundamentally in two physical characteristics: process boundaries (where execution occurs) and database boundaries (how state is isolated).

1. Traditional Monolith
┌─────────────────────────────────────────────────────────┐
│ Single OS Process / Container Runtime                   │
│                                                         │
│  ┌──────────────┐   ┌──────────────┐   ┌─────────────┐  │
│  │ Order Domain │◄─►│ Stock Domain │◄─►│ Bill Domain │  │
│  └──────────────┘   └──────────────┘   └─────────────┘  │
│         │                  │                  │         │
│         └──────────────────┼──────────────────┘         │
│                            ▼                            │
│           Direct In-Memory Calls / Shared Heap          │
└────────────────────────────┼────────────────────────────┘
                             ▼
               ┌───────────────────────────┐
               │ Unified Relational Schema │
               │ (Shared Tables & FKs)     │
               └───────────────────────────┘

2. Modular Monolith (Modulith)
┌─────────────────────────────────────────────────────────┐
│ Single OS Process / Container Runtime                   │
│                                                         │
│  ┌─────────────────┐   ┌─────────────────┐              │
│  │  Orders Module  │   │ Inventory Module│              │
│  │ [Public Facade] │   │ [Public Facade] │              │
│  │ [Internal Code] │   │ [Internal Code] │              │
│  └────────┬────────┘   └────────▲────────┘              │
│           │                     │                       │
│           └── In-Process Events ┘                       │
│               & Typed DTO Calls                         │
└───────────────────────────┬─────────────────────────────┘
                            ▼
              ┌───────────────────────────┐
              │ Segregated Schemas (DBMS) │
              │ [orders] [inv] [billing]  │
              │ (No Cross-Schema FKs)     │
              └───────────────────────────┘

3. Microservices Architecture
┌──────────────────┐  Network (gRPC/HTTP) ┌──────────────────┐
│  Orders Service  │◄────────────────────►│Inventory Service │
│ (Process / Pod)  │                      │ (Process / Pod)  │
└────────┬─────────┘                      └────────┬─────────┘
         │                                         │
         ▼                                         ▼
┌──────────────────┐                      ┌──────────────────┐
│ Dedicated DB     │                      │ Dedicated DB     │
│ (Orders Schema)  │                      │ (Inv Schema)     │
└──────────────────┘                      └──────────────────┘

Traditional Monolith

In a traditional monolith, the entire application codebase compiles into a single executable artifact or runs within a single runtime process (e.g., a single Node.js process, Go binary, or Java JVM). All application domains share the same execution heap and interact directly with a unified relational database instance containing cross-table foreign keys and unified transactions.

Modular Monolith (Modulith)

A modular monolith retains the single deployment artifact and single execution runtime of a monolith, but strictly enforces structural boundaries between distinct domain modules at compile time or via static analysis linting. Modules expose explicitly defined public interfaces (facades and Data Transfer Objects) while keeping internal business logic, data models, and persistence mechanisms private. Crucially, database tables are segregated into distinct logical schemas per module, prohibiting cross-module SQL joins and foreign key constraints.

Microservices

A microservices architecture decomposes the business domain into a collection of independently deployable services. Each service runs in its own operating system process (typically as a containerized pod in an orchestration system like Kubernetes) and communicates with other services over physical network boundaries using protocols like gRPC, REST (HTTP/JSON), or asynchronous message brokers (e.g., Apache Kafka, RabbitMQ). Each service adheres strictly to the Database-per-Service pattern, maintaining exclusive ownership of its data store.


2. Comprehensive Architectural Comparison

The following table compares the three topologies across eight critical engineering and operational dimensions:

| Dimension | Traditional Monolith | Modular Monolith | Microservices | | :--- | :--- | :--- | :--- | | Deployment Topology | Single deployable binary or container image. | Single deployable binary or container image. | Multiple independently deployable services and containers. | | Inter-Module Communication | Direct in-memory function calls (< 100 ns latency). | In-memory facade calls and in-process event dispatchers (< 100 ns latency). | Network RPC, HTTP, or message queues (typically 1–50 ms latency). | | Data Consistency | Immediate ACID guarantees via local database transactions. | Immediate ACID within module schemas; eventual consistency via in-process events across modules. | Eventual consistency; requires Saga patterns, Outbox patterns, and compensating transactions. | | Operational Tooling | Basic logging, APM agent, and single CI/CD pipeline. | Basic logging, APM agent, and single CI/CD pipeline with module test filters. | Complex: Service mesh, distributed tracing (OpenTelemetry), API gateway, Kubernetes, and service discovery. | | Team Autonomy & Release Velocity | Low at scale; deployments are coupled; potential merge contention. | Moderate to high; teams own discrete modules; deployment remains unified. | High; autonomous teams release services independently without cross-team lockstep. | | Blast Radius & Fault Isolation | Wide; an unhandled exception or memory leak can crash the entire process. | Wide at runtime (shared process); narrow at design time (clean boundaries). | Narrow; failure in one service is isolated by circuit breakers and fallback policies. | | Infrastructure Cost | Low; high CPU/memory utilization on minimal compute instances. | Low; high compute efficiency with zero inter-service network transit costs. | High; compute overhead per service, orchestrator overhead, and network egress costs. | | Refactoring & Domain Evolution | Trivial; IDE refactoring tools, compiler type-checking across domains. | Straightforward; internal module refactoring is isolated; interfaces are versioned. | Painful; requires cross-repository changes, backward-compatible API contracts, and dual deployments. |


3. Traditional Monoliths: Undervalued Strengths and Real Failure Modes

The traditional monolith has suffered from unwarranted negative branding in industry discourse. In many engineering contexts, a well-structured monolith is the most productive and reliable architecture available.

The Genuine Advantages

  1. ACID Transaction Guarantees: A relational database transaction provides Atomicity, Consistency, Isolation, and Durability out of the box. When an e-commerce workflow creates an order, decrements inventory, and issues an invoice in a single database transaction, the database engine guarantees that either all operations succeed or none do. There are no partial writes, orphaned rows, or split-brain inconsistencies.
  2. Zero-Network Function Latency: Calling a function in another domain within the same runtime executes in CPU nanoseconds. There is no payload serialization, JSON parsing, TCP socket management, TLS termination, or network jitter.
  3. Deterministic Debugging and Profiling: Developers can clone the repository, run a single startup command, attach an IDE debugger, set breakpoints across different domains, and inspect the entire call stack. Profiling tools (such as CPU flame graphs and heap snapshots) capture full execution paths without requiring distributed context propagation.
  4. Instantaneous Atomic Refactoring: When a business concept changes, a developer can rename a field or reorganize a data structure across the entire codebase in a single Git commit. The compiler or type checker verifies correctness instantly before the code ever reaches production.
In-Memory Function Call (Monolith):
Caller ──────[ Direct Pointer / Stack Call (~10-50 ns) ]──────► Callee

Network Remote Procedure Call (Microservices):
Caller ──► Serialize ──► TLS/TCP ──► Wire (~1-20 ms) ──► TLS/TCP ──► Deserialize ──► Callee

Inherent Failure Modes at Scale

Despite its operational efficiency, a traditional monolith experiences severe friction when an organization or codebase grows beyond certain thresholds:

  • The Big Ball of Mud: Without physical or compile-time enforcement mechanisms, code entropy inevitably degrades the architecture. Developers take shortcuts: importing private internal helpers across features, executing cross-table SQL joins that bypass business invariants, and creating circular dependencies. Over time, the codebase becomes a tightly coupled web where changing one feature breaks unexpected downstream components.
  • Global Blast Radius: Because all domains execute within a single operating system process, an unhandled exception, infinite loop, memory leak, or thread pool exhaustion in a non-critical feature (such as generating a PDF report) can crash the entire application instance, taking down critical revenue-generating workflows.
  • Continuous Deployment Gridlock: When 50 to 100 engineers push code to a single repository, continuous integration pipelines become bottlenecks. Test suites that take 45 minutes to execute create merge queues. If a single test in an unrelated domain fails, the entire release is blocked. Teams find themselves coordinating releases through release trains and staging freezes.

4. Modular Monoliths: Enforced Boundaries Without the Distributed Tax

The modular monolith (often termed Modulith) addresses the organizational and code-quality failure modes of the monolith without introducing the operational overhead of microservices.

A modular monolith is a single deployable application structured into distinct, encapsulated modules corresponding to business capabilities (Bounded Contexts in Domain-Driven Design).

┌────────────────────────────────────────────────────────────────────────┐
│ Modular Monolith Process                                               │
│                                                                        │
│  ┌───────────────────────┐             ┌────────────────────────────┐  │
│  │     Orders Module     │             │      Inventory Module      │  │
│  │                       │             │                            │  │
│  │  ┌─────────────────┐  │             │  ┌──────────────────────┐  │  │
│  │  │  Public Facade  │──┼──DTO Call───┼─►│    Public Facade     │  │  │
│  │  │  & Contracts    │  │             │  │    & Contracts       │  │  │
│  │  └────────┬────────┘  │             │  └──────────┬───────────┘  │  │
│  │           │           │             │             │              │  │
│  │  ┌────────▼────────┐  │             │  ┌──────────▼───────────┐  │  │
│  │  │ Private Domain  │  │             │  │ Private Domain       │  │  │
│  │  │ Logic & Models  │  │             │  │ Logic & Models       │  │  │
│  │  └─────────────────┘  │             │  └──────────────────────┘  │  │
│  └───────────┬───────────┘             └─────────────┬──────────────┘  │
│              │                                       │                 │
│              └───────────────┐       ┌───────────────┘                 │
│                              ▼       ▼                                 │
│                 ┌────────────────────────────────┐                     │
│                 │   In-Process Domain EventBus   │                     │
│                 │   (Subscriber Error Isolation) │                     │
│                 └────────────────────────────────┘                     │
└────────────────────────────────────────────────────────────────────────┘

Module Boundary Enforcement Rules

To prevent architectural erosion, a modular monolith must enforce four structural constraints:

  1. Explicit Public Contracts: Each module must define a clear public interface (often a Facade or Service Interface). All internal classes, helper functions, and database entities must remain private to that module.
  2. Data Transfer Objects (DTOs): Cross-module communication must exchange plain, immutable DTOs or primitive values. Passing active domain entities, ORM models, or database query builders across module boundaries is strictly prohibited.
  3. Logical Database Segregation: While modules may share a physical database cluster, each module must own its dedicated database schema (e.g., PostgreSQL schemas orders, inventory, and billing). Cross-schema SQL joins and cross-schema foreign keys are forbidden. Data required from another module must be queried via that module's public facade or synchronized asynchronously via domain events.
  4. Compile-Time or Linter Validation: Boundary rules should not rely on developer discipline alone. They must be validated automatically during build or continuous integration:
    • TypeScript / Node.js: Use ESLint rules such as import/no-restricted-paths or TypeScript Project References to prevent unauthorized imports across feature folders.
    • Java / Kotlin: Use tools like ArchUnit or Spring Modulith to assert architectural boundaries in unit tests.
    • Go: Utilize Go's internal/ package conventions, where packages located under an internal directory can only be imported by code within the parent directory tree.
    • .NET / C#: Use internal access modifiers across project assemblies, or tools like ArchUnitNET.

Inter-Module Communication Patterns

Within a modular monolith, modules interact through two distinct patterns:

  • Synchronous Queries via Facades: When a module needs data from another module to fulfill a request, it invokes a method on the target module's public facade. This execution is an in-memory function call with zero network overhead, returning a typed DTO.
  • Asynchronous Decoupling via Domain Events: When state changes in one module (e.g., an order is placed), it publishes an event to an in-process event bus. Subscribing modules (such as Inventory or Billing) receive and process the event asynchronously. By isolating subscriber errors, a failure in billing does not abort the order placement process.

This design provides an evolutionary runway: if a specific module later requires independent scaling, specialized hardware (e.g., GPU nodes), or a dedicated deployment lifecycle, it can be extracted into an independent microservice with minimal friction because its boundaries, DTOs, and event contracts are already established.


5. Microservices: The Distributed Systems Reality and Taxes

A microservices architecture is not merely a monolith split into multiple code repositories; it is an explicit transition into distributed systems engineering.

The Database-per-Service Imperative

The foundational rule of microservices is that every microservice must own its own data store. Two services must never read or write directly to the same database tables.

Anti-Pattern: The Distributed Monolith (Shared Database)
┌──────────────────────┐              ┌──────────────────────┐
│    Orders Service    │              │  Inventory Service   │
└──────────┬───────────┘              └──────────┬───────────┘
           │                                     │
           └──────────────────┬──────────────────┘
                              ▼
                 ┌──────────────────────────┐
                 │ Shared Relational DB     │
                 │ (Coupled Schema & Locks) │
                 └──────────────────────────┘
Result: Distributed network latency + coupled schema migrations = Worst of both worlds.

If multiple services query the same relational database, you have created a distributed monolith: you suffer the latency, operational complexity, and deployment overhead of distributed services while retaining the schema coupling and lock contention of a shared database.

The Fallacies of Distributed Computing

In 1994, L. Peter Deutsch and colleagues at Sun Microsystems articulated the Fallacies of Distributed Computing—assumptions that engineers frequently make when transitioning from single-process systems to distributed networks:

  1. The network is reliable.
  2. Latency is zero.
  3. Bandwidth is infinite.
  4. The network is secure.
  5. Topology doesn't change.
  6. There is one administrator.
  7. Transport cost is zero.
  8. The network is homogeneous.

In a monolith, function calls are guaranteed to complete or throw an immediate in-memory exception. In microservices, every network hop introduces the possibility of partial failure: dropped packets, connection timeouts, DNS resolution delays, and TCP socket exhaustion.

The Distributed Systems Tax

Adopting microservices introduces significant architectural overhead:

  • Latency Amplification: A user request that previously executed in 15 milliseconds inside a single process may now traverse an API gateway, an authentication service, an order service, an inventory service, and a pricing service. If each network hop takes 10–20 milliseconds, the cumulative latency increases dramatically, particularly at the 99th percentile (p99 tail latency).
  • Observability Overhead: When an error occurs, diagnosing the root cause across 20 distinct services requires distributed tracing infrastructure (e.g., OpenTelemetry, Jaeger), unified trace context propagation (W3C TraceContext headers), centralized log aggregation, and metric correlation.
  • Infrastructure and Operational Footprint: Instead of maintaining one CI/CD pipeline and one deployment target, operations teams must manage container registries, Kubernetes manifests, Helm charts, ingress controllers, service meshes (e.g., Istio or Linkerd), secret rotation, and automated canary deployments.

Distributed Data Consistency: Sagas and Outbox Patterns

Because distributed transactions (such as Two-Phase Commit / 2PC) do not scale reliably across modern cloud environments due to network partitions and lock overhead, microservices rely on eventual consistency.

The Transactional Outbox Pattern

When a service updates its database and publishes a message to a broker (like Kafka or RabbitMQ), a dual-write problem emerges: if the database commit succeeds but the network fails before publishing the event, the rest of the system becomes inconsistent. The Transactional Outbox Pattern solves this by saving the event into an outbox table within the same local database transaction as the business entity update. A separate relay process (or Change Data Capture tool like Debezium) reads the outbox table and guarantees message delivery to the broker.

Transactional Outbox Flow:
┌─────────────────────────────────────────────────────────────┐
│ Orders Service                                              │
│                                                             │
│  1. Begin Local DB Transaction                              │
│  2. INSERT INTO orders (...)                                │
│  3. INSERT INTO outbox_events (event_id, payload, status)   │
│  4. Commit Local Transaction                                │
└──────────────────────────────┬──────────────────────────────┘
                               │
                               ▼
                ┌──────────────────────────────┐
                │ Outbox Relay / CDC Worker    │
                │ (Reads unread outbox events) │
                └──────────────┬───────────────┘
                               │
                               ▼
                ┌──────────────────────────────┐
                │ Message Broker (Kafka/NATS)  │
                └──────────────────────────────┘

The Saga Pattern

Long-running business workflows spanning multiple services cannot rely on ACID rollbacks. Instead, teams implement the Saga Pattern, an ordered sequence of local transactions:

  • Choreography: Each service executes its local transaction and publishes an event that triggers the next service.
  • Orchestration: A central orchestrator service (or workflow engine like Temporal) explicitly commands participant services to execute transactions.

If a step fails midway (e.g., payment is declined after inventory was reserved), the Saga must execute compensating transactions in reverse order to explicitly undo previously committed state (e.g., releasing reserved inventory). Designing, testing, and monitoring compensating transactions introduces substantial architectural complexity.


6. Conway's Law and Socio-Technical Alignment

Architecture is not merely an abstract technical topology; it is fundamentally a socio-technical system. In 1968, computer programmer Melvin Conway formulated what is now known as Conway's Law:

"Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations."

When an architectural topology conflicts with the organizational structure of the engineering team, operational friction inevitably follows.

Socio-Technical Mapping:

Team Structure                             Optimal Architecture
┌──────────────────────────────┐          ┌──────────────────────────────┐
│ Single Co-located Team       │ ───────► │ Traditional Monolith         │
│ (1–12 engineers, 1 backlog)  │          │ (Zero boundary overhead)     │
└──────────────────────────────┘          └──────────────────────────────┘

┌──────────────────────────────┐          ┌──────────────────────────────┐
│ Multiple Product Teams       │ ───────► │ Modular Monolith             │
│ (15–80 engineers,            │          │ (Domain modules with clear   │
│  stream-aligned domains)     │          │  code ownership)             │
└──────────────────────────────┘          └──────────────────────────────┘

┌──────────────────────────────┐          ┌──────────────────────────────┐
│ Multi-Department Org         │ ───────► │ Microservices                │
│ (100+ engineers, distinct    │          │ (Autonomous deployments,     │
│  business units & budgets)   │          │  strict network boundaries)  │
└──────────────────────────────┘          └──────────────────────────────┘

The Premature Microservices Anti-Pattern

A frequent failure mode in engineering teams is adopting microservices prematurely. When a team of 8 to 15 engineers attempts to maintain 25 microservices, the cognitive load becomes unsustainable. Engineers spend more time debugging Docker networks, updating shared client SDKs, orchestrating cross-service integration tests, and maintaining Kubernetes manifests than writing product features.

Microservices are an organizational scaling mechanism: they allow an enterprise with hundreds of engineers across multiple departments to deploy software without blocking one another. For smaller teams, a modular monolith offers the clean domain separation of microservices without the organizational and operational drag.


7. Concrete Implementation: Modular Monolith in TypeScript

The following self-contained TypeScript example demonstrates the core architectural patterns of a modular monolith:

  1. Strictly typed domain events and DTOs defining boundaries between contexts.
  2. An in-process domain event bus with subscriber error isolation using Promise.allSettled.
  3. Encapsulated domain modules (OrderModule, InventoryModule, and BillingModule) that interact exclusively through public contracts and asynchronous events.
  4. An architectural boundary enforcement validator demonstrating programmatic verification of dependency rules.
// ============================================================================
// 1. Shared Kernel: Contracts, DTOs, and Domain Events
// ============================================================================

export interface DomainEvent {
  readonly eventId: string;
  readonly eventType: string;
  readonly occurredAt: Date;
}

export interface OrderItemDTO {
  readonly sku: string;
  readonly quantity: number;
  readonly unitPriceCents: number;
}

export interface OrderCreatedPayload {
  readonly orderId: string;
  readonly customerId: string;
  readonly items: ReadonlyArray<OrderItemDTO>;
  readonly totalCents: number;
}

export interface OrderCreatedEvent extends DomainEvent {
  readonly eventType: 'order.created';
  readonly payload: OrderCreatedPayload;
}

// Data Transfer Object returned across module boundaries
export interface OrderSummaryDTO {
  readonly orderId: string;
  readonly customerId: string;
  readonly status: 'pending' | 'confirmed' | 'cancelled';
  readonly totalCents: number;
}

// ============================================================================
// 2. In-Process Domain Event Bus with Subscriber Error Isolation
// ============================================================================

export type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;

export interface IEventBus {
  publish<T extends DomainEvent>(event: T): Promise<void>;
  subscribe<T extends DomainEvent>(eventType: T['eventType'], handler: EventHandler<T>): void;
}

export class InProcessEventBus implements IEventBus {
  private readonly handlers = new Map<string, Array<EventHandler<any>>>();

  public subscribe<T extends DomainEvent>(eventType: T['eventType'], handler: EventHandler<T>): void {
    const existing = this.handlers.get(eventType) ?? [];
    existing.push(handler);
    this.handlers.set(eventType, existing);
  }

  public async publish<T extends DomainEvent>(event: T): Promise<void> {
    const registeredHandlers = this.handlers.get(event.eventType) ?? [];
    if (registeredHandlers.length === 0) {
      return;
    }

    // Execute handlers with subscriber error isolation using Promise.allSettled.
    // A failure in one subscriber does not abort execution or crash sibling subscribers.
    const results = await Promise.allSettled(
      registeredHandlers.map((handler) => handler(event))
    );

    for (const result of results) {
      if (result.status === 'rejected') {
        // In production, record to an error telemetry sink or dead-letter table
        console.error(
          `[EventBus Error] Handler failed for ${event.eventType} (${event.eventId}):`,
          result.reason instanceof Error ? result.reason.message : result.reason
        );
      }
    }
  }
}

// ============================================================================
// 3. Encapsulated Bounded Context Modules
// ============================================================================

/**
 * Inventory Module: Manages stock levels within its private data store.
 */
export class InventoryModule {
  // Private module state: inaccessible outside this module
  private readonly stockLevels = new Map<string, number>([
    ['SKU-LAPTOP-01', 20],
    ['SKU-MONITOR-02', 45],
  ]);

  constructor(eventBus: IEventBus) {
    // Subscribes to domain events asynchronously without direct coupling to OrderModule
    eventBus.subscribe<OrderCreatedEvent>('order.created', async (event) => {
      await this.reserveInventory(event.payload.items);
    });
  }

  private async reserveInventory(items: ReadonlyArray<OrderItemDTO>): Promise<void> {
    for (const item of items) {
      const current = this.stockLevels.get(item.sku) ?? 0;
      if (current < item.quantity) {
        throw new Error(`Insufficient stock for SKU ${item.sku}. Available: ${current}`);
      }
      this.stockLevels.set(item.sku, current - item.quantity);
    }
  }

  // Public facade method for query operations
  public getAvailableStock(sku: string): number {
    return this.stockLevels.get(sku) ?? 0;
  }
}

/**
 * Billing Module: Manages payments and invoices within its private state.
 */
export class BillingModule {
  private readonly settledPayments = new Map<string, number>();

  constructor(eventBus: IEventBus) {
    eventBus.subscribe<OrderCreatedEvent>('order.created', async (event) => {
      await this.processSettlement(event.payload.orderId, event.payload.totalCents);
    });
  }

  private async processSettlement(orderId: string, amountCents: number): Promise<void> {
    this.settledPayments.set(orderId, amountCents);
  }

  public isPaymentSettled(orderId: string): boolean {
    return this.settledPayments.has(orderId);
  }
}

/**
 * Order Module: Owns order lifecycle and acts as the orchestrating domain.
 */
export class OrderModule {
  // Private state representing orders table
  private readonly orders = new Map<string, {
    orderId: string;
    customerId: string;
    items: OrderItemDTO[];
    totalCents: number;
    status: 'pending' | 'confirmed' | 'cancelled';
  }>();

  constructor(private readonly eventBus: IEventBus) {}

  public async placeOrder(
    customerId: string,
    items: ReadonlyArray<OrderItemDTO>
  ): Promise<OrderSummaryDTO> {
    if (items.length === 0) {
      throw new Error('An order must contain at least one item.');
    }

    const orderId = `ord_${Math.random().toString(36).substring(2, 10)}`;
    const totalCents = items.reduce(
      (sum, item) => sum + item.quantity * item.unitPriceCents,
      0
    );

    const orderRecord = {
      orderId,
      customerId,
      items: [...items],
      totalCents,
      status: 'confirmed' as const,
    };

    // Commit to internal module state
    this.orders.set(orderId, orderRecord);

    // Publish event across the in-process boundary
    await this.eventBus.publish<OrderCreatedEvent>({
      eventId: `evt_${Math.random().toString(36).substring(2, 10)}`,
      eventType: 'order.created',
      occurredAt: new Date(),
      payload: {
        orderId,
        customerId,
        items: orderRecord.items,
        totalCents,
      },
    });

    // Return an immutable DTO — never leak private entities or active references
    return {
      orderId: orderRecord.orderId,
      customerId: orderRecord.customerId,
      status: orderRecord.status,
      totalCents: orderRecord.totalCents,
    };
  }

  public getOrderSummary(orderId: string): OrderSummaryDTO | null {
    const record = this.orders.get(orderId);
    if (!record) {
      return null;
    }
    return {
      orderId: record.orderId,
      customerId: record.customerId,
      status: record.status,
      totalCents: record.totalCents,
    };
  }
}

// ============================================================================
// 4. Architectural Boundary Enforcement Validator
// ============================================================================

export interface ModuleBoundaryPolicy {
  readonly moduleName: string;
  readonly allowedDependencies: ReadonlyArray<string>;
}

export class ArchitectureBoundaryValidator {
  /**
   * Verifies that observed module dependencies conform to declared architectural policies.
   */
  public static validate(
    policies: ReadonlyArray<ModuleBoundaryPolicy>,
    observedDependencies: ReadonlyMap<string, ReadonlyArray<string>>
  ): { readonly isValid: boolean; readonly violations: ReadonlyArray<string> } {
    const violations: string[] = [];

    for (const [moduleName, actualDeps] of observedDependencies.entries()) {
      const policy = policies.find((p) => p.moduleName === moduleName);
      if (!policy) {
        violations.push(`Unregistered module '${moduleName}' detected without an explicit policy.`);
        continue;
      }

      for (const dep of actualDeps) {
        if (!policy.allowedDependencies.includes(dep)) {
          violations.push(
            `Architectural violation: Module '${moduleName}' illegally imports '${dep}'. ` +
            `Permitted dependencies: [${policy.allowedDependencies.join(', ')}]`
          );
        }
      }
    }

    return {
      isValid: violations.length === 0,
      violations,
    };
  }
}

8. Pragmatic Decision Framework & Selection Matrix

When selecting or evolving an architectural topology, evaluate the system against objective operational and organizational criteria rather than aesthetic preference.

Decision Flowchart:
Is your engineering team larger than 80-100 engineers with distinct departments?
  ├── YES ──► Do distinct domains require independent deployment or divergent hardware/stacks?
  │             ├── YES ──► Microservices Architecture
  │             └── NO  ──► Modular Monolith
  └── NO  ──► Is your domain well-understood with stable, established boundaries?
                ├── YES ──► Modular Monolith (Clean boundaries, low overhead)
                └── NO  ──► Traditional Monolith (Rapid prototyping, instant refactoring)

Architectural Decision Matrix

| Organizational & Technical Profile | Recommended Topology | Primary Rationale | | :--- | :--- | :--- | | Early-stage startup / MVP (1–10 engineers, exploratory domain) | Traditional Monolith | Maximizes development velocity; avoids premature boundaries when the domain model changes weekly. | | Growing scale-up (15–80 engineers, multiple product squads) | Modular Monolith | Enables domain encapsulation and team ownership; avoids the high operational tax of microservices. | | Enterprise / Multi-department (100+ engineers, distinct business units) | Microservices | Decouples release cadences; enables organizational autonomy where network overhead is an acceptable trade-off. | | Radically divergent runtime requirements (e.g., Python ML vs Go I/O core) | Hybrid / Targeted Microservices | Isolates specialized hardware (GPU instances) or language ecosystems from the core business engine. | | Strict regulatory isolation (e.g., PCI-DSS cardholder vault, HIPAA data) | Microservices (Targeted) | Constrains compliance audit scope to a dedicated, physically isolated service and data store. |


9. Incremental Migration: The Strangler Fig Pattern

Teams frequently make the mistake of attempting a "Big Bang" architectural rewrite—halting feature development for twelve months to rewrite a monolith into microservices from scratch. Industry experience consistently demonstrates that Big Bang rewrites carry catastrophic risk: business requirements evolve during the rewrite, edge cases in the existing system are forgotten, and release deadlines slip.

The standard, battle-tested pattern for architectural migration is the Strangler Fig Pattern (described by Martin Fowler). Under this pattern, capabilities are extracted incrementally from the existing monolith until the new architecture gradually supersedes the old.

Strangler Fig Migration Phases:

Phase 1: Edge Interception
Client Requests ──► [ Reverse Proxy / API Gateway ] ──► Monolith

Phase 2: Targeted Extraction & Shadow Verification
Client Requests ──► [ Reverse Proxy / API Gateway ]
                           │                   │
                     (90% Traffic)       (10% Shadow)
                           │                   │
                           ▼                   ▼
                      [ Monolith ]      [ New Microservice ]
                           │                   │
                           └─► CDC Sync Outbox ┘

Phase 3: Complete Cutover
Client Requests ──► [ Reverse Proxy / API Gateway ] ──► [ New Microservice ]
                                                              │
                      [ Monolith (Decommissioned Module) ] ◄──┘

Step-by-Step Extraction Workflow

  1. Modularize Inside the Monolith: Before extracting a service over the network, first refactor the target domain into a clean, encapsulated module inside the monolith. Segregate its database tables into a distinct schema and eliminate cross-module SQL joins.
  2. Deploy an Edge Proxy: Introduce a reverse proxy (such as Envoy, NGINX, or a cloud API gateway) in front of the monolith. Initially, 100% of traffic continues to route to the monolith.
  3. Build the Extracted Service: Deploy the extracted capability as an independent service with its own dedicated database.
  4. Synchronize Data Asynchronously: Implement data replication using the Transactional Outbox pattern and Change Data Capture (CDC via tools like Debezium) to synchronize state between the monolith and the new service without distributed locks.
  5. Shadow Traffic / Dual-Run Testing: Configure the proxy or application layer to fork incoming read traffic, executing requests against both the monolith and the new service. Compare the responses asynchronously to verify functional parity and latency characteristics.
  6. Reroute Live Traffic & Decommission: Once parity is verified under production load, switch 100% of live traffic to the new microservice. Finally, delete the legacy module and schema from the monolith.

10. Production Architecture Verification Checklist

Before committing to an architectural migration or declaring an architecture production-ready, verify your system against this operational checklist:

Boundary & Code Integrity

  • [ ] Are module or service boundaries enforced via automated linters, compilers, or test suites (e.g., ESLint restricted paths, ArchUnit)?
  • [ ] Are all cross-boundary interactions defined using immutable Data Transfer Objects (DTOs) rather than active ORM entities or query builders?
  • [ ] Are circular dependencies between modules or services prohibited and tested in continuous integration?

Data Consistency & Storage

  • [ ] In a monolith or modular monolith, are database schemas logically segregated to prevent cross-domain SQL joins and direct foreign keys?
  • [ ] In microservices, does every service own a dedicated database instance or private schema inaccessible to any other service?
  • [ ] Are cross-boundary distributed updates handled via asynchronous eventual consistency (Transactional Outbox + Saga) rather than distributed 2PC transactions?

Resilience & Failure Handling

  • [ ] Are inter-service network calls protected by explicit timeouts, retries with exponential backoff and jitter, and circuit breakers?
  • [ ] Are mutating HTTP or RPC endpoints idempotent, utilizing unique idempotency keys to prevent duplicate transactions during network retries?
  • [ ] In a modular monolith, is the in-process event bus architected with subscriber error isolation to prevent one handler's failure from crashing the publisher?

Observability & Operations

  • [ ] Does every cross-service network request propagate a unified distributed trace context (W3C TraceContext headers)?
  • [ ] Are structured logs tagged with correlation IDs, allowing end-to-end request tracing across processes?
  • [ ] Has the operational cost (infrastructure compute, network egress, monitoring tooling, and maintenance overhead) been explicitly quantified against business value?

11. Conclusion

The choice between a Monolith, a Modular Monolith, and Microservices is not a judgment on engineering capability or technological sophistication. It is a strategic balance between operational complexity, transactional guarantees, organizational alignment, and system throughput.

  • Choose a Traditional Monolith when speed of iteration, domain exploration, and low operational overhead are paramount.
  • Choose a Modular Monolith when your team is scaling and requires clear domain boundaries, clean code ownership, and high architectural maintainability without paying the distributed systems tax.
  • Choose Microservices when your organization has grown into multiple autonomous departments with independent release cycles, or when specific services demand isolated scaling profiles and distinct technology stacks.

The most resilient architectures begin modular from day one. Enforce clean boundaries in code and data early; let measurable scaling bottlenecks and organizational growth—rather than architectural fashion—dictate when to take on the complexities of distributed systems.

Related articles

Illustration of modular software architecture layers connected across a production system
Architecture

When Should You Split a Monolith Into Services?

September 27, 2025 · 17 min read

A practical engineering guide to evaluating monolith decomposition: the 5 valid architectural drivers, distributed monolith risks, Strangler Fig pattern, and data boundaries.

Software ArchitectureMicroservicesModular MonolithDistributed Systems