Monolith to Microservices#

Migrating from a monolith to microservices is one of the most impactful — and risky — architectural transitions a team can undertake. Success requires a deliberate, incremental strategy rather than a big-bang rewrite.


Why Migrate?#

DriverMonolith PainMicroservices Solution
Deployment speedEntire app redeployed for any changeDeploy services independently
Team scalingMerge conflicts, coordination overheadTeams own independent services
Technology lock-inOne stack for everythingPolyglot — best tool per service
Fault isolationOne bug crashes everythingFailures contained per service
ScalabilityScale entire app for one hot pathScale individual services

When NOT to Migrate#

  • Small team (< 10 engineers)
  • Well-understood domain with stable requirements
  • No deployment bottleneck
  • You cannot invest in platform infrastructure (CI/CD, observability, service mesh)

The Modular Monolith — First Step#

Before extracting services, modularize within the monolith:

graph TB
    subgraph "Modular Monolith"
        A[Orders Module] --- B[Payments Module]
        B --- C[Inventory Module]
        A --- C
        D[Shared Kernel]
        A --> D
        B --> D
        C --> D
    end

Characteristics#

PropertyTraditional MonolithModular Monolith
Module boundariesUnclear, spaghettiExplicit interfaces
Data accessAny code accesses any tableEach module owns its tables
DependenciesCircular, implicitDeclared, enforced
DeploymentSingle unitSingle unit (but extractable)

How to Modularize#

  1. Identify bounded contexts (DDD)
  2. Establish module boundaries with explicit public APIs
  3. Prohibit cross-module database access (use module APIs)
  4. Enforce boundaries with linting/architecture tests (ArchUnit, deptry)

Strangler Fig Pattern#

Incrementally replace monolith functionality by routing traffic to new services:

graph LR
    Client --> Proxy[API Gateway / Proxy]
    Proxy -->|/orders| MS[Orders Microservice]
    Proxy -->|/payments| Monolith
    Proxy -->|/inventory| Monolith
    Proxy -->|/users| MS2[Users Microservice]

Steps#

  1. Intercept — Place a proxy/gateway in front of the monolith
  2. Implement — Build the new service for one capability
  3. Route — Redirect traffic for that capability to the new service
  4. Retire — Remove the old code from the monolith once validated

Migration Timeline#

gantt
    title Strangler Fig Migration
    dateFormat  YYYY-MM
    section Orders
    Build service       :2024-01, 3M
    Shadow traffic      :2024-04, 1M
    Route 100%          :2024-05, 1M
    Remove from mono    :2024-06, 1M
    section Payments
    Build service       :2024-04, 4M
    Shadow traffic      :2024-08, 1M
    Route 100%          :2024-09, 1M
    Remove from mono    :2024-10, 1M

Branch by Abstraction#

When you cannot route traffic externally (shared library, internal module), use an abstraction layer:

graph TB
    subgraph "Step 1: Introduce Abstraction"
        Code[Calling Code] --> Interface[Interface/Adapter]
        Interface --> OldImpl[Old Implementation]
    end
graph TB
    subgraph "Step 2: Build New Implementation"
        Code2[Calling Code] --> Interface2[Interface/Adapter]
        Interface2 --> OldImpl2[Old Implementation]
        Interface2 -.->|feature flag| NewImpl[New Service Client]
    end
graph TB
    subgraph "Step 3: Switch and Remove"
        Code3[Calling Code] --> Interface3[Interface/Adapter]
        Interface3 --> NewImpl2[New Service Client]
    end

Process#

  1. Create an abstraction (interface) around the code to replace
  2. Refactor consumers to use the abstraction
  3. Build new implementation behind the abstraction
  4. Use feature flags to gradually shift traffic
  5. Remove old implementation once new one is validated

Database Decomposition#

The hardest part of service extraction is splitting the database.

Strategies#

StrategyApproachRisk
Shared database (temporary)Services share tables during migrationCoupling, schema changes break both
Database viewService reads from a view over monolith DBRead-only, limited evolution
Data syncReplicate data via CDC/eventsEventual consistency
Database per serviceEach service owns its dataRequires data migration

Decomposition Phases#

graph LR
    subgraph "Phase 1: Shared DB"
        S1[Service A] --> DB[(Shared DB)]
        M1[Monolith] --> DB
    end
graph LR
    subgraph "Phase 3: Separate DBs"
        S3[Service A] --> DBA[(Service A DB)]
        M3[Monolith] --> DBM[(Monolith DB)]
        S3 -.->|events| M3
    end

Handling Cross-Service Queries#

PatternUse When
API compositionSimple joins across 2-3 services
CQRS read modelComplex queries spanning many services
Event-driven materialized viewReal-time aggregation needed
Data lake / warehouseAnalytics, reporting

Data Synchronization#

Change Data Capture (CDC)#

graph LR
    MonoDB[(Monolith DB)] -->|CDC| Debezium[Debezium/CDC Tool]
    Debezium -->|events| Kafka[Event Stream]
    Kafka --> ServiceDB[(Service DB)]

Dual-Write Problem#

Never write to two systems in the same operation — one may fail:

# BAD: dual write
save_to_database(order)
publish_event(order_created)  # what if this fails?

# GOOD: Outbox pattern
save_to_database(order)
save_to_outbox_table(order_created_event)  # same transaction
# Background process publishes from outbox

Saga Pattern for Distributed Transactions#

sequenceDiagram
    participant Orders
    participant Payments
    participant Inventory
    Orders->>Payments: Reserve funds
    Payments-->>Orders: Funds reserved
    Orders->>Inventory: Reserve stock
    Inventory-->>Orders: Stock reserved
    Orders->>Orders: Confirm order
    Note over Orders,Inventory: If any step fails → compensating transactions

Migration Strategies Summary#

StrategyBest ForComplexity
Strangler figUser-facing features with clear routingMedium
Branch by abstractionInternal modules, shared librariesMedium
Parallel runCritical paths needing validationHigh
Bubble context (DDD)New features that don’t fit the monolithLow
Database-first splitTightly coupled data domainsHigh
  1. Modularize the monolith (enforce boundaries)
  2. Extract the easiest service first (build platform capabilities)
  3. Extract the most painful service next (highest deployment friction)
  4. Repeat — each extraction teaches you and improves tooling
  5. Accept the residual — some monolith code may never be worth extracting

Anti-Patterns#

Anti-PatternProblemAlternative
Big-bang rewriteHigh risk, no value until completeIncremental strangler fig
Distributed monolithServices coupled by shared DB/sync callsAsync events, own data
Nano-servicesToo many tiny services, operational overheadRight-size boundaries
Shared libraries for business logicCoupling through shared codeDuplicate simple logic
Skipping the modular monolithExtract without understanding boundariesModularize first

Key Takeaways#

  1. Start with a modular monolith — enforce boundaries before extracting services.
  2. The strangler fig pattern is the safest migration strategy — incremental, reversible, proven.
  3. Database decomposition is the hardest part — plan for it explicitly with CDC or outbox patterns.
  4. Never do a big-bang rewrite — it almost always fails.
  5. Accept eventual consistency — synchronous cross-service calls create distributed monoliths.
  6. Each extraction should deliver independent value — don’t extract for the sake of microservices.
  7. Build platform capabilities (CI/CD, observability, service mesh) before or alongside extraction.