When Should You Split a Monolith Into Services?

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

September 27, 202517 min readOpenStair Engineering
Illustration of modular software architecture layers connected across a production system

When Should You Split a Monolith Into Services?

Decomposing a monolith into microservices is frequently portrayed as an inevitable milestone of engineering maturity. Engineering blogs celebrate migrations away from monolithic codebases, while conference talks praise the agility of decoupled microservices. In production, however, breaking apart a unified application is not a badge of honor; it is an architectural transaction where you trade one set of operational and structural problems for a fundamentally different, often more punishing set.

When a monolith is split prematurely or for the wrong reasons, the result is almost never an agile fleet of autonomous services. Instead, teams produce a distributed monolith: an architecture that combines the tight coupling, fragile deployment dependencies, and shared schema locks of a monolith with the network latency, partial failure states, eventual consistency bugs, and operational overhead of distributed computing.

Splitting a monolith must be driven by strict, verifiable business and operational constraints—never by aesthetic preference or resume-driven development. This guide details the foundational principles governing service extraction, establishes the modular monolith as the robust baseline, analyzes the five valid architectural drivers for decomposition, exposes common anti-patterns, and outlines concrete migration mechanics using the Strangler Fig pattern and database decomposition.


1. Foundational Architectural Principles

Before drawing network boundaries around application components, architects must reckon with two decades of hard-won distributed systems experience codified by industry pioneers.

Martin Fowler: The "MonolithFirst" Rule and the Microservice Premium

Software architect Martin Fowler observed a consistent historical trend across successful and unsuccessful microservice implementations:

  1. Almost all successful microservice stories started with a monolith that grew too large and was broken down incrementally.
  2. Almost all systems that started as microservices from day one ended up in serious trouble.

This empirical observation led Fowler to formulate the MonolithFirst strategy. In the early stages of a product or domain, the boundaries between business capabilities are fluid and poorly understood. If you draw service boundaries across shifting domain contours, refactoring requires coordinating schema changes, API contracts, network serializations, and cross-service deployments. Within a monolith, refactoring a boundary is an internal code reorganization supported by compiler type-checking and automated refactoring tools.

Productivity / Capability
       │
       │                               /  Microservices
       │                              /   (High capability at high complexity)
       │                             /
       │                            /
       │                           / ◄── Complexity Cutoff Point
       │                          /      (Monolith coordination costs exceed
       │  Monolith               /        distributed systems tax)
       │  ──────────────────────/
       │                       /
       │                      /
       │                     /
       │                    /
       └───────────────────/──────────────────────── System Scale & Complexity

Fowler also coined the term Microservice Premium. Microservices introduce a heavy baseline tax: distributed logging, correlation IDs, network partition handling, service discovery, API gateways, container orchestration, and asynchronous data synchronization. For an application with moderate scale and small-to-midsize engineering teams, this operational tax drags down developer productivity. Only when an organization grows past the complexity cutoff point—where monolithic merge trains, CI bottlenecks, and organizational contention outweigh the distributed tax—does paying the microservice premium yield net-positive velocity.

Simon Brown: The Structure Imperative

Software architecture expert Simon Brown summarized the core fallacy of service decomposition:

"If you cannot build a well-structured monolith, what makes you think you can build well-structured microservices?"

Microservices are not an antidote for messy code. If an engineering team cannot maintain clean interfaces, explicit domain boundaries, and disciplined dependency flows inside a single repository where compilers enforce structural contracts, introducing network hops will only amplify the dysfunction. Spaghetti code inside a monolith becomes "distributed spaghetti" across a network, where a single user action triggers a cascade of synchronous HTTP calls across twenty services.


2. The Baseline: Why the Modular Monolith Is the Default Production Topology

Before considering an extraction into standalone microservices, teams must evaluate whether their problems can be resolved within a Modular Monolith.

A modular monolith preserves a single deployment artifact and runtime process while enforcing strict encapsulation between logical bounded contexts. In-process modules communicate through well-defined public facades and asynchronous domain events rather than reaching into one another's internal classes or database tables.

TRADITIONAL MONOLITH               MODULAR MONOLITH                MICROSERVICES
(Tangled Dependencies)          (Strict In-Process Contexts)       (Independent Processes)

┌───────────────────────┐       ┌────────────────────────┐       ┌───────┐     ┌───────┐
│  Web / API Layer      │       │    Public API Facade   │       │Order  │     │Payment│
└───────────┬───────────┘       └───────────┬────────────┘       │Service│     │Service│
            ▼                               ▼                    └───┬───┘     └───┬───┘
┌───────────────────────┐       ┌───────────┴────────────┐           │             │
│   Shared Heap Space   │       │  [Module: Orders]      │           ▼             ▼
│  ┌─────┐   ┌────────┐ │       │  ┌──────────────────┐  │       ┌───────┐     ┌───────┐
│  │Order│◄─►│Payment │ │       │  │ Private Domain   │  │       │Order  │     │Payment│
│  └──┬──┘   └───┬────┘ │       │  └────────┬─────────┘  │       │DB     │     │DB     │
│     │          │      │       │           ▼            │       └───────┘     └───────┘
│  ┌──▼──────────▼────┐ │       │  [In-Memory Event Bus] │           ▲             ▲
│  │   Inventory      │ │       │           ▲            │           │             │
│  └──────────────────┘ │       │           ▼            │           └──────┬──────┘
└───────────┬───────────┘       │  [Module: Inventory]   │                  │
            │                   └───────────┬────────────┘           Network Calls
            ▼                               ▼                        (REST / gRPC / Kafka)
┌───────────────────────┐       ┌────────────────────────┐
│ Single Database       │       │ Single Database        │
│ (Shared SQL Schema)   │       │ (Logical Schemas)      │
└───────────────────────┘       └────────────────────────┘

In-Process Execution vs. Network Latency

The performance physics of in-process communication compared to distributed RPC are separated by orders of magnitude:

  • In-Memory Function Invocations: Passing a typed object reference across module boundaries takes sub-microsecond time (≈10–50 nanoseconds\approx 10\text{--}50\text{ nanoseconds}). It does not allocate TCP sockets, require TLS handshakes, or serialize payloads into JSON or Protocol Buffers.
  • Network Roundtrips: A lightweight HTTP/REST or gRPC call between two microservices within the same cloud data center typically incurs 5 to 50 milliseconds5\text{ to }50\text{ milliseconds} of latency under production load when accounting for DNS resolution, TLS termination, network queues, deserialization, and kernel context switching.

A workflow requiring ten sequential domain interactions takes less than 1 millisecond1\text{ millisecond} in a modular monolith. In a microservices mesh with deep synchronous call graphs, that same workflow easily inflates to 150–500 milliseconds150\text{--}500\text{ milliseconds} before factoring in network jitter or retries.

Master Evaluation Matrix: Monolith vs. Modular Monolith vs. Microservices

| Architectural Dimension | Traditional Monolith | Modular Monolith | Distributed Microservices | | :--- | :--- | :--- | :--- | | Deployment Autonomy | None (atomic single deployment) | None (single artifact release) | High (independent CI/CD per service) | | Transactional Consistency | Immediate (Single ACID commit) | Immediate (ACID across modules) | Eventual (Sagas, Outbox, compensating actions) | | Operational Complexity | Low (Single process, single DB) | Low-to-Medium (Single runtime, linted boundaries) | Very High (Kubernetes, service mesh, OpenTelemetry) | | Latency & Throughput | Minimal latency (in-memory calls) | Minimal latency (sub-microsecond) | Substantial network & serialization tax | | Team Scalability | Poor past 30–50 engineers | Strong up to 100+ engineers | High (hundreds to thousands of engineers) | | Blast Radius & Fault Isolation | Zero (crash drops whole system) | Process-shared (unhandled panic crashes heap) | Complete process and host isolation | | Debuggability & Tracing | Trivial (single stack trace & logs) | Trivial (single thread/process trace) | Complex (distributed traces across hosts) |


3. The 5 Valid Architectural Drivers for Splitting

If modular monoliths are operationally superior for most applications, when is splitting a service genuinely justified? Service extraction is valid only when physical process separation solves a problem that logical in-process boundaries fundamentally cannot address.

There are exactly five legitimate architectural drivers:

                      VALID EXTRACTION DRIVERS
                                │
    ┌───────────────────────────┼───────────────────────────┐
    ▼                           ▼                           ▼
1. Deployment Velocity     2. Asymmetric Compute       3. Blast Radius
   & Team Autonomy            & Resource Scaling          & Fault Isolation
   (Conway's Law)             (CPU/GPU vs I/O CRUD)       (Protect Core Workloads)
                                │
                ┌───────────────┴───────────────┐
                ▼                               ▼
        4. Compliance & Regulatory      5. Technology Heterogeneity
           Perimeter Minimization          (Legitimate Polyglot Demands)
           (PCI-DSS / HIPAA)               (Rust SIMD vs Python ML)

Driver 1: Independent Deployment Velocity and Team Autonomy (Conway's Law)

Melvin Conway observed in 1967:

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

When an engineering organization expands past 80–100 engineers grouped into multiple stream-aligned domain teams, the monolithic deployment pipeline becomes an organizational bottleneck:

  • Merge Train Contention: Dozens of pull requests merge into main daily, causing frequent integration breakages and test failures.
  • Release Train Blockages: If the checkout team needs to release an urgent hotfix, their deployment is blocked because an unrelated reporting module failed an end-to-end integration test in CI.
  • CI Queue Serialization: Test suites for large monolithic codebases frequently stretch to 45–60 minutes. Running these suites for every commit across dozens of teams paralyzes delivery cadence.

Extracting a bounded context into an independently deployable microservice allows a dedicated team to manage its own Git repository, automated CI pipeline, and continuous deployment cadence without coordinating release schedules with the rest of the company.

Driver 2: Asymmetric Resource and Scaling Characteristics

Different business domains often exhibit radically divergent hardware requirements:

  • Heavy Compute / GPU Inference: An image processing or machine learning inference module requires memory-heavy instances equipped with hardware accelerators (e.g., NVIDIA Tensor Core GPUs). Standard CRUD APIs require cheap, general-purpose compute with modest CPU and memory.
  • High-Concurrency Connection Holding: A real-time WebSocket or Server-Sent Events (SSE) notification hub must maintain 50,000 persistent socket connections, consuming OS file descriptors and socket buffers. Standard web endpoints are stateless and ephemeral.
  • Video Transcoding and PDF Generation: Memory-intensive batch processing workloads can spike CPU utilization to 100% for minutes at a time.

In a monolith, scaling the compute resources for the CPU-bound video processor requires scaling the entire monolithic deployment across dozens of expensive GPU or high-compute nodes, multiplying infrastructure costs. Extracting the specialized workload into an isolated service allows granular horizontal autoscaling tailored precisely to that workload's profile.

Driver 3: Blast Radius and Fault Isolation

In a shared process runtime, all modules share the same memory heap, thread pool, and garbage collector:

MONOLITH BLAST RADIUS: UNPROTECTED
┌────────────────────────────────────────────────────────┐
│ Shared Process Heap                                    │
│                                                        │
│  ┌─────────────────────────┐  ┌─────────────────────┐  │
│  │ Core Checkout Engine    │  │ Headless Chrome PDF │  │
│  │ ($10M/day revenue flow) │  │ Report Generator    │  │
│  └─────────────────────────┘  └──────────┬──────────┘  │
│               ▲                          │             │
│               │                          ▼             │
│               │             Uncontrolled Memory Leak   │
│               │                          │             │
│               └──────────────────────────┴─────────────┼──► OOM Killer Kills Process
└────────────────────────────────────────────────────────┘    (Checkout Crashes!)

Consider a concrete production scenario: An application includes an invoice export feature using a headless browser engine to generate PDFs. Under heavy load, the PDF generator leaks memory or enters an unconstrained CPU loop. The underlying operating system's Out-Of-Memory (OOM) killer fires, terminating the entire process. Active checkout sessions, order submissions, and payment authorizations running in the same process are aborted simultaneously.

Extracting the volatile, memory-intensive PDF generation or third-party webhook dispatchers into standalone worker services isolates their blast radius. If the PDF service crashes or runs out of memory, the core revenue-generating transaction paths continue operating uninterrupted.

Driver 4: Compliance and Regulatory Perimeter Minimization

Strict regulatory standards—such as the Payment Card Industry Data Security Standard (PCI-DSS) or the Health Insurance Portability and Accountability Act (HIPAA)—impose rigorous operational controls:

  • Detailed audit logging of all system accesses.
  • Strict network segmentation and firewall isolation.
  • Mandatory third-party vulnerability scans and source code audits.
  • Severe restrictions on engineer SSH/production access.

If credit card ingestion or patient health records reside inside a monolithic codebase, the entire monolith, its supporting databases, its deployment pipelines, and every engineer with commit access fall inside the compliance audit scope.

By extracting cardholder data handling into a dedicated, hardened, minimally scoped tokenization microservice, you shrink the compliance boundary (Cardholder Data Environment / CDE) to a single repository and a handful of isolated servers. The main monolith and the majority of the engineering organization remain outside the high-cost audit perimeter.

Driver 5: Technology Heterogeneity (Legitimate Polyglot Needs)

Technology heterogeneity should be treated with extreme skepticism because every added language incurs secondary costs in tooling, libraries, container base images, and developer onboarding. However, legitimate requirements occasionally dictate polyglot execution:

  • Low-Latency Systems Programming: Implementing cryptographic proof verification, real-time audio DSP, or high-throughput financial matching engines where garbage collection pauses are unacceptable, mandating Rust or C++.
  • Scientific Computing and AI: Direct integration with PyTorch, Hugging Face transformers, or NumPy, where the native Python ecosystem holds a decisive advantage over other runtimes.
  • High-Throughput Network Daemons: Writing low-level TCP proxies or custom protocol adapters where Go or Erlang provides battle-tested concurrency primitives.

When runtime ecosystem requirements conflict fundamentally with the monolith's primary language, service extraction becomes an engineering necessity.


4. The False Drivers: Anti-Patterns That Breed the "Distributed Monolith"

Most service extraction projects fail because they are initiated for the wrong reasons. The three most destructive false drivers are detailed below.

False Driver 1: Splitting Spaghetti Code to "Enforce Clean Architecture"

The most prevalent architectural error is attempting to fix poorly structured code by carving it into microservices.

When an unmodularized monolith is decomposed without first untangling internal dependencies, the result is the dreaded Distributed Monolith. In this anti-pattern, modules that previously invoked each other through internal function calls now invoke each other across HTTP or gRPC.

DISTRIBUTED MONOLITH ANTI-PATTERN
┌──────────────────┐    Synchronous HTTP Call    ┌──────────────────┐
│  Order Service   ├────────────────────────────►│ Inventory Service│
└────────┬─────────┘                             └────────┬─────────┘
         │                                                │
         │ Synchronous HTTP Call                          │ Synchronous HTTP Call
         ▼                                                ▼
┌──────────────────┐                             ┌──────────────────┐
│ Customer Service │◄────────────────────────────┤ Payment Service  │
└────────┬─────────┘    Circular Dependency      └────────┬─────────┘
         │                                                │
         └───────────────────────┬────────────────────────┘
                                 ▼
                     ┌───────────────────────┐
                     │ Shared SQL Database   │ ◄── Schema Lock & Connection
                     │ (Tables tightly joined│     Pool Starvation
                     └───────────────────────┘

The distributed monolith exhibits the worst attributes of both worlds:

  1. Network Cascades: A single client request initiates a web of synchronous inter-service RPC calls. If service D experiences a 200ms latency spike, services A, B, and C exhaust their connection pools waiting for responses, cascading into system-wide failure.
  2. Coordinated Deployments: Because boundaries were drawn arbitrarily around intertwined code, deploying a feature requires deploying services A, B, and C simultaneously in an exact sequence. Independent deployability is completely lost.
  3. Distributed Deadlocks: Circular synchronous RPC calls create distributed locking issues that are notoriously difficult to reproduce in local staging environments.

False Driver 2: Premature Decomposition for "Hypothetical Scale"

Early-stage startups and new product teams often justify microservices by citing the architectural designs of Netflix, Amazon, or Uber.

In reality, a modern relational database like PostgreSQL or MySQL running on standard cloud compute hardware can comfortably sustain thousands to tens of thousands of complex queries per second when properly indexed, pooled, and cached. Most production web applications never exceed a few hundred queries per second throughout their entire commercial lifespan.

Decomposing an application before reaching true physical bottlenecks squanders engineering bandwidth on Kubernetes configurations, Istio service meshes, and eventual consistency plumbing rather than delivering product value.

False Driver 3: The "Shared Database" Anti-Pattern

A common failure mode during monolith decomposition is creating separate microservice codebases that continue to query the same shared relational database.

THE SHARED DATABASE ANTI-PATTERN: FALSE ISOLATION
┌─────────────────────────┐          ┌─────────────────────────┐
│     Billing Service     │          │      Order Service      │
│   (Independent Repo)    │          │   (Independent Repo)    │
└────────────┬────────────┘          └────────────┬────────────┘
             │                                    │
             │ Direct SQL Query                   │ Direct SQL Query
             │ (SELECT * FROM orders...)          │ (UPDATE orders SET...)
             ▼                                    ▼
       ┌────────────────────────────────────────────────┐
       │             Shared Production Database         │
       │                                                │
       │  ┌──────────────────┐    ┌──────────────────┐  │
       │  │ orders table     │◄──►│ customers table  │  │
       │  └──────────────────┘    └──────────────────┘  │
       │  Foreign Key Constraints & Hidden Schema Locks │
       └────────────────────────────────────────────────┘

When two services access the same database tables:

  • Schema Migration Locks: If the Order team alters a column in the orders table, they inadvertently break SQL queries running in the Billing service. True deployment independence is an illusion.
  • Connection Pool Exhaustion: A burst of traffic to the Billing service consumes all available database connection pool slots, starving the Order service and bringing down core transactions.
  • Transaction and Locking Contention: Row-level locks and table-level metadata locks acquired by one service stall query execution in the other.

Comparison: Shared Database vs. Database-per-Service

| Consideration | Shared Database (Anti-Pattern) | Database-per-Service (True Microservice) | | :--- | :--- | :--- | | Schema Evolution | High risk; breaking changes break other services | Zero risk to other services; internal schema is private | | Data Access | Direct SQL queries and cross-table joins | Exclusively via public APIs or asynchronous event streams | | Operational Isolation | None; heavy queries in service A degrade service B | Absolute; dedicated compute, memory, and connection pools | | Data Consistency | Immediate relational foreign keys and ACID transactions | Eventual consistency; requires Saga orchestration or Outbox | | Deployment Dependency| Coordinated releases for schema updates | Fully decoupled release cycles |


5. The Microservice Complexity Tax: What Teams Often Forget

When you decompose a monolith, you trade code-level refactoring challenges for distributed systems engineering challenges. Teams must be prepared to pay the following operational taxes:

1. Distributed Observability and Tracing

In a monolith, tracing an error requires inspecting a single stack trace and correlating logs on one server. In a distributed architecture, an HTTP request traverses an API gateway, an authentication service, an order service, and a payment processor.

Teams must implement end-to-end distributed tracing using standards like W3C Trace Context (traceparent headers) and instrumentation frameworks such as OpenTelemetry. Every service must capture and propagate span IDs, export telemetry to central collectors, and manage the storage costs associated with high-volume tracing data.

2. Network Fallibility and Partial Failures

The First Fallacy of Distributed Computing states: "The network is reliable." In production, the network is fundamentally unreliable. Sockets drop, DNS lookups timeout, and cloud routing tables experience transient hiccups.

Every network call introduced by microservices must incorporate:

  • Timeouts: Strict connect and read deadlines to prevent indefinite thread hangs.
  • Exponential Backoff with Jitter: Retrying transient failures without synchronizing retry storms that overwhelm recovering upstreams.
  • Circuit Breakers: Cutting off traffic to a failing downstream service (e.g., via the bulkhead or circuit breaker pattern) to fail fast and preserve local capacity.
  • Idempotency Keys: Guaranteeing that retried network requests (e.g., charging a payment or creating an order) do not produce duplicate financial side-effects.

3. The Dual-Write Problem and Eventual Consistency

In a monolith, updating an order status and notifying inventory occurs in a single local database transaction:

BEGIN TRANSACTION;
  UPDATE orders SET status = 'CONFIRMED' WHERE id = 42;
  UPDATE inventory SET stock = stock - 1 WHERE sku = 'WIDGET-1';
COMMIT;

If the server crashes mid-execution, the relational database rolls back the transaction entirely. Consistency is absolute.

In microservices, the Order Service updates its database and must inform the Inventory Service over Kafka or RabbitMQ. If the Order Service writes to its database and crashes before publishing the message, data drifts out of sync. If it publishes the message first and the database write fails, inventory is reserved for an order that does not exist.

Solving this requires implementing the Transactional Outbox Pattern and Change Data Capture (CDC), replacing simple SQL commits with distributed eventual consistency pipelines.


6. Decomposition Patterns: How to Safely Extract Services

When decomposition is warranted, never perform a "big bang" rewrite. A complete rewrite of an existing production monolith into microservices carries an extraordinarily high failure rate. Instead, extract bounded contexts incrementally using proven migration patterns.

1. The Strangler Fig Pattern

First described by Martin Fowler in 2004 (and updated in 2020), the Strangler Fig Pattern is named after the Australian strangler fig vine, which germinates in the upper branches of a host tree, gradually sends roots down to the soil, and slowly envelops and replaces the host tree over decades.

In software architecture, you place an API Gateway or Reverse Proxy in front of the legacy monolith. Incoming client traffic hits the proxy first. When a specific domain is ready to be extracted, the proxy is configured to route traffic for that specific path to the new microservice, while transparently forwarding all remaining traffic to the monolith.

STRANGLER FIG TRAFFIC INTERCEPTION
                                 Incoming Client Requests
                                            │
                                            ▼
                           ┌─────────────────────────────────┐
                           │    Edge Reverse Proxy / Gateway │
                           │  (Matches Paths & Injects Span) │
                           └────────┬───────────────┬────────┘
                                    │               │
        Route: /api/v1/payments/*   │               │ Route: /api/v1/*
        (Extracted Domain)          │               │ (Residual Legacy Monolith)
                                    ▼               ▼
                        ┌──────────────────┐  ┌──────────────────┐
                        │ Payment Service  │  │ Legacy Monolith  │
                        │ (New Container)  │  │ (Old Process)    │
                        └────────┬─────────┘  └────────┬─────────┘
                                 ▼                     ▼
                        ┌──────────────────┐  ┌──────────────────┐
                        │ Dedicated DB     │  │ Monolith DB      │
                        └──────────────────┘  └──────────────────┘

The migration proceeds in four iterative phases:

  1. Intercept: Direct all user traffic through an edge proxy routing 100% of requests to the existing monolith.
  2. Transform: Implement the extracted bounded context (e.g., Payments) in a new microservice with its own isolated database.
  3. Coexist: Shift routing rules at the proxy layer so that payment endpoints route to the new microservice, while residual monolith routes remain untouched.
  4. Deprecate: Safely delete the decommissioned payment code and legacy database tables from the monolith.

2. Database Decomposition Mechanics

Splitting application code is straightforward; splitting stateful databases without data corruption or downtime is the true engineering hurdle.

The Transactional Outbox Pattern

To eliminate dual-write inconsistencies when publishing domain events to Kafka or RabbitMQ, services persist their domain entities and outbound events within the same local ACID transaction:

TRANSACTIONAL OUTBOX PATTERN
┌────────────────────────────────────────────────────────┐
│ Payment Microservice                                   │
│                                                        │
│  Atomic Local DB Transaction                           │
│  ┌──────────────────────────────────────────────────┐  │
│  │ 1. INSERT INTO payments (id, amount, status)     │  │
│  │ 2. INSERT INTO outbox (event_id, payload, state) │  │
│  └────────────────────────┬─────────────────────────┘  │
└───────────────────────────┼────────────────────────────┘
                            ▼
           ┌──────────────────────────────────┐
           │ Payment Database                 │
           │  ┌──────────┐      ┌──────────┐  │
           │  │ payments │      │  outbox  │  │
           │  └──────────┘      └────┬─────┘  │
           └─────────────────────────┼────────┘
                                     │
                 Transaction Log / Polling
                                     │
                                     ▼
           ┌──────────────────────────────────┐
           │ Outbox Publisher Engine          │
           │ (Debezium CDC / Poller Process)  │
           └─────────────────┬────────────────┘
                             │
                             ▼
                 Message Broker (Kafka / SNS)

Because both writes occur in a single database transaction, the event is guaranteed to be recorded if and only if the business record is saved.

Change Data Capture (CDC)

An outbox publisher process or Change Data Capture connector (such as Debezium) tails the database transaction write-ahead log (WAL in PostgreSQL, Binlog in MySQL). When it detects a new entry in the outbox table, it converts the entry into an event and publishes it to Apache Kafka. Once delivery is confirmed by the broker, the outbox record is marked processed.

Saga Orchestration

When a business process spans multiple extracted services (e.g., placing an order requires reserving inventory, processing payment, and issuing a shipping label), distributed two-phase commits (2PC) must be avoided due to severe coordinator latency and single-point-of-failure vulnerabilities.

Instead, teams implement Sagas: a series of local transactions coordinated through an Orchestrator (e.g., Temporal, AWS Step Functions, or custom state machines). If step 3 (Payment) fails, the orchestrator issues compensating transactions back to step 2 (releasing reserved inventory) to maintain business consistency.


7. Concrete TypeScript Implementation

To understand the practical contrast between in-process modular boundaries and distributed routing, consider two production-grade TypeScript implementations.

Pattern 1: Modular Monolith In-Process Bounded Context with Typed Domain Events

The following example demonstrates how a modular monolith achieves strict structural boundaries and decoupled communication entirely in-process, bypassing the network tax.

/**
 * Modular Monolith: In-Process Bounded Context Architecture
 *
 * Demonstrates strict module encapsulation, public facades, and asynchronous
 * in-memory domain events within a single Node.js runtime process.
 */

// 1. Strongly Typed Domain Events
export interface DomainEvent {
  readonly eventId: string;
  readonly occurredAt: Date;
  readonly eventName: string;
}

export interface OrderPlacedEvent extends DomainEvent {
  readonly eventName: "OrderPlaced";
  readonly orderId: string;
  readonly customerId: string;
  readonly totalAmountCents: number;
  readonly items: ReadonlyArray<{ sku: string; quantity: number; unitPriceCents: number }>;
}

// 2. In-Memory Domain Event Bus
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;

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

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

  public async publish<T extends DomainEvent>(event: T): Promise<void> {
    const registered = this.handlers.get(event.eventName) ?? [];
    
    // Dispatch in-memory asynchronously without blocking the primary thread
    await Promise.all(
      registered.map(async (handler) => {
        try {
          await handler(event);
        } catch (error) {
          // Structured logging preserves failure details without crashing the dispatcher
          console.error(`[EventBus] Handler failed for event ${event.eventName}:`, error);
        }
      })
    );
  }
}

// 3. Encapsulated Bounded Context Types
export interface CreateOrderCommand {
  readonly customerId: string;
  readonly items: ReadonlyArray<{ sku: string; quantity: number; unitPriceCents: number }>;
}

export interface OrderRecord {
  readonly id: string;
  readonly customerId: string;
  readonly totalAmountCents: number;
  readonly status: "PENDING" | "CONFIRMED" | "CANCELLED";
  readonly createdAt: Date;
}

// 4. Public Module Contract (Facade)
export interface IOrderModule {
  placeOrder(command: CreateOrderCommand): Promise<OrderRecord>;
  getOrder(orderId: string): Promise<OrderRecord | null>;
}

// 5. Order Bounded Context Implementation
export class OrderModule implements IOrderModule {
  // Encapsulated data store: private to this module
  private readonly orders = new Map<string, OrderRecord>();

  constructor(private readonly eventBus: InProcessEventBus) {}

  public async placeOrder(command: CreateOrderCommand): Promise<OrderRecord> {
    if (command.items.length === 0) {
      throw new Error("Cannot place an order with zero line items.");
    }

    const totalAmountCents = command.items.reduce(
      (sum, item) => sum + item.quantity * item.unitPriceCents,
      0
    );

    const orderId = `ord_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
    const record: OrderRecord = {
      id: orderId,
      customerId: command.customerId,
      totalAmountCents,
      status: "CONFIRMED",
      createdAt: new Date(),
    };

    // Atomic state transition within module boundary
    this.orders.set(orderId, record);

    // Asynchronous in-memory event dispatch (sub-microsecond execution)
    const event: OrderPlacedEvent = {
      eventId: `evt_${Date.now()}`,
      occurredAt: new Date(),
      eventName: "OrderPlaced",
      orderId: record.id,
      customerId: record.customerId,
      totalAmountCents: record.totalAmountCents,
      items: command.items,
    };

    await this.eventBus.publish(event);

    return record;
  }

  public async getOrder(orderId: string): Promise<OrderRecord | null> {
    return this.orders.get(orderId) ?? null;
  }
}

// 6. Subscribing Bounded Context (Inventory Module in the Same Process)
export class InventorySubscriber {
  constructor(eventBus: InProcessEventBus) {
    eventBus.subscribe<OrderPlacedEvent>("OrderPlaced", async (event) => {
      // In-process consumption avoids HTTP parsing, TLS encryption, and network latency
      for (const item of event.items) {
        console.log(
          `[Inventory] Reserving ${item.quantity} units of SKU ${item.sku} for order ${event.orderId}`
        );
      }
    });
  }
}

Pattern 2: Strangler Fig Edge Reverse Proxy and Route Dispatcher

The following implementation shows how an edge reverse proxy intercepts traffic, inspects routes, and dynamically directs extracted domains to isolated microservices while proxying residual traffic to the monolith.

/**
 * Strangler Fig Reverse Proxy Dispatcher
 *
 * Intercepts incoming HTTP requests, matches path prefixes, routes extracted
 * domain endpoints to independent microservices, and proxies residual requests
 * to the legacy monolith with correlation tracking and strict timeouts.
 */

import * as http from "node:http";
import { URL } from "node:url";

export interface RouteRule {
  readonly pathPrefix: string;
  readonly targetBaseUrl: string;
  readonly rewritePrefix?: string;
  readonly timeoutMs: number;
}

export class StranglerFigGateway {
  private readonly routes: RouteRule[];
  private readonly defaultMonolithUrl: string;

  constructor(config: { routes: RouteRule[]; defaultMonolithUrl: string }) {
    this.routes = config.routes;
    this.defaultMonolithUrl = config.defaultMonolithUrl;
  }

  public handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
    const correlationId =
      (Array.isArray(req.headers["x-correlation-id"])
        ? req.headers["x-correlation-id"][0]
        : req.headers["x-correlation-id"]) ||
      `req_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;

    const parsedUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
    const pathname = parsedUrl.pathname;

    // Match path against registered extracted routes
    const matchedRule = this.routes.find((rule) => pathname.startsWith(rule.pathPrefix));

    let targetBase = this.defaultMonolithUrl;
    let targetPath = pathname;

    if (matchedRule) {
      targetBase = matchedRule.targetBaseUrl;
      if (matchedRule.rewritePrefix !== undefined) {
        targetPath = pathname.replace(matchedRule.pathPrefix, matchedRule.rewritePrefix);
      }
    }

    const targetUrl = new URL(targetPath + parsedUrl.search, targetBase);
    const isExtractedService = Boolean(matchedRule);

    this.forwardRequest(
      req,
      res,
      targetUrl,
      correlationId,
      isExtractedService,
      matchedRule?.timeoutMs || 10000
    );
  }

  private forwardRequest(
    clientReq: http.IncomingMessage,
    clientRes: http.ServerResponse,
    targetUrl: URL,
    correlationId: string,
    isExtractedService: boolean,
    timeoutMs: number
  ): void {
    const headers = { ...clientReq.headers };
    headers["x-correlation-id"] = correlationId;
    headers["x-forwarded-by"] = "strangler-fig-gateway";
    headers["x-route-destination"] = isExtractedService ? "microservice" : "legacy-monolith";
    headers.host = targetUrl.host;

    const proxyOptions: http.RequestOptions = {
      protocol: targetUrl.protocol,
      hostname: targetUrl.hostname,
      port: targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80),
      path: targetUrl.pathname + targetUrl.search,
      method: clientReq.method,
      headers,
      timeout: timeoutMs,
    };

    const proxyReq = http.request(proxyOptions, (upstreamRes) => {
      clientRes.writeHead(upstreamRes.statusCode || 500, upstreamRes.headers);
      upstreamRes.pipe(clientRes);
    });

    proxyReq.on("timeout", () => {
      proxyReq.destroy();
      if (!clientRes.headersSent) {
        clientRes.writeHead(504, { "Content-Type": "application/json" });
        clientRes.end(
          JSON.stringify({
            error: "Gateway Timeout",
            correlationId,
            target: targetUrl.origin,
          })
        );
      }
    });

    proxyReq.on("error", (err) => {
      if (!clientRes.headersSent) {
        clientRes.writeHead(502, { "Content-Type": "application/json" });
        clientRes.end(
          JSON.stringify({
            error: "Bad Gateway",
            message: err.message,
            correlationId,
          })
        );
      }
    });

    clientReq.pipe(proxyReq);
  }
}

8. Master Comparison: Valid vs. Invalid Extraction Drivers

To evaluate candidate extraction proposals objectively, engineering leadership should apply the following decision framework across real-world scenarios:

| Scenario / Symptom | Proposed Solution | Underlying Reality | Recommended Architectural Action | | :--- | :--- | :--- | :--- | | "Codebase is tangled spaghetti; tests take hours." | Split into 10 microservices | Splitting tangled code produces a distributed monolith with cascading timeouts | Refactor into a Modular Monolith with linted boundary rules (e.g. ESLint boundaries) | | "We want to support 10M users next year." | Rewrite as microservices immediately | Premature scaling burns capital on infrastructure before finding product-market fit | Keep monolith; optimize database indexes, add Redis caching, and tune connection pools | | "PDF export triggers OOM kills on checkout." | Extract PDF generator to standalone worker | Valid: Memory leaks in batch jobs must not crash mission-critical transaction flows | Extract Service: Carve out PDF generator onto dedicated compute with autoscaling | | "Two teams fight over database schema migrations." | Two microservices querying the same DB | Shared databases retain schema lockups and eliminate operational isolation | Decouple data ownership; assign private schemas or complete database-per-service | | "PCI-DSS audit costs $250k across the entire monolith." | Extract payment processing into isolated service | Valid: Minimizing the Cardholder Data Environment (CDE) drastically shrinks audit perimeter | Extract Service: Build a hardened tokenization microservice; remove card data from monolith | | "45-minute CI queues block hotfixes across 120 engineers." | Decompose along bounded business lines | Valid: Conway's Law friction justifies microservices to achieve independent team velocity | Extract Service: Incrementally migrate stream-aligned domains using Strangler Fig |


9. Monolith Decomposition Readiness Checklist

Before committing engineering resources to service extraction, evaluate the candidate domain against this five-pillar readiness framework:

DECOMPOSITION READINESS AUDIT
┌─────────────────────────────────────────────────────────────┐
│ [ ] 1. Domain Boundary Clarity                              │
│        Context is cleanly isolated in code; zero circular   │
│        dependencies with other monolith modules.            │
├─────────────────────────────────────────────────────────────┤
│ [ ] 2. Data Decoupling Feasibility                          │
│        No direct cross-domain SQL joins or foreign keys;    │
│        candidate owns its transactional schema entirely.    │
├─────────────────────────────────────────────────────────────┤
│ [ ] 3. CI/CD & Deployment Automation                        │
│        Automated deployment pipeline can provision, test,   │
│        and deploy the standalone service in < 10 minutes.   │
├─────────────────────────────────────────────────────────────┤
│ [ ] 4. Distributed Observability Baseline                   │
│        OpenTelemetry tracing, structured logging, and APM   │
│        alerting are already active across the gateway.      │
├─────────────────────────────────────────────────────────────┤
│ [ ] 5. Dedicated Team Ownership                            │
│        A stream-aligned team is equipped to own the service │
│        lifecycle, deployment on-call, and SLA targets.      │
└─────────────────────────────────────────────────────────────┘

If your team cannot check off all five criteria, extracting a microservice will introduce more operational friction than it resolves. Focus on modularizing the monolith and establishing observability first.


10. Conclusion & Strategic Decision Heuristics

The decision to split a monolith into microservices is not an aesthetic choice; it is a calculated operational trade-off. Monoliths excel at maintaining data consistency, minimizing system latency, and keeping infrastructure simple. Microservices excel at scaling deployment velocity across large engineering organizations and isolating volatile compute workloads.

When evaluating your architecture, adhere to these production heuristics:

  1. Default to the Modular Monolith: Enforce clean interfaces, module encapsulation, and in-memory event dispatching before introducing network hops.
  2. Never Split to Fix Bad Code: Organize code internally first; physical network separation magnifies structural flaws.
  3. Validate Real Bottlenecks: Decompose only when driven by organizational scale (Conway's Law), asymmetric hardware demands, blast radius isolation, compliance perimeters, or specialized runtimes.
  4. Isolate Data First: A service without its own private database is not a microservice—it is a distributed monolith.
  5. Migrate with the Strangler Fig: Avoid catastrophic full rewrites. Intercept traffic at the edge, extract domains incrementally, and decommission legacy code iteratively.

Architectural maturity is not measured by how many microservices your team maintains. It is measured by how quickly and reliably you deliver business value with the simplest architecture that satisfies your operational constraints.

Related articles

Architectural comparison between Monolith, Modular Monolith, and Microservices topologies
Architecture

Monolith vs Modular Monolith vs Microservices

September 26, 2025 · 16 min read

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

Software ArchitectureMicroservicesModular MonolithSystem Design