SystemDesign.website
Track 7 ยท Module 7.5Distributed Reliability & DevOps

Monolith to Microservices Evolution

When to keep your monolith, how to execute Martin Fowler's Strangler Fig pattern without downtime, decouple monolithic databases with Change Data Capture (CDC), and protect clean architectures using Anti-Corruption Layers (ACL).

โฑ๏ธ Estimated Reading: 30 minsโ€ขPrerequisites: Modules 7.1 – 7.4โ€ขInteractive Lab: Strangler Fig Decomposer
๐Ÿข ARCHITECTURE PHILOSOPHY ยท FIRST PRINCIPLES

1. The Monolith: Hero, Not Villain

In modern engineering hype, “monolith” is frequently used as a derogatory term. But the reality is starkly different: almost every single billion-dollar tech company started as a monolith. Shopify, GitHub, Stripe, Stack Overflow, and Basecamp ran billions of dollars of transactions through monolithic architectures.

โœ… Monolith Superpowers

  • Zero Network Latency: Module-to-module communication is an in-memory pointer dereference (nanoseconds), not an HTTP/gRPC round-trip (milliseconds).
  • True ACID Transactions: Atomic changes spanning multiple business domains using a single database transaction rollback.
  • Simple Deployments: One artifact, one pipeline, one server fleet to monitor. Zero service mesh overhead.
  • Refactoring Freedom: Renaming methods and restructuring schemas is a clean IDE refactor, not a multi-team API contract negotiation.

โš ๏ธ The Breaking Points

  • The Two-Pizza Team Limit: When 200+ engineers commit to the same repo, merge conflicts, test suite bottlenecks, and coordination costs explode.
  • Blast Radius: A memory leak or infinite loop in the unessential PDF generator can bring down the checkout service.
  • Heterogeneous Resource Mismatches: Scaling an image processing module forces you to scale 100 instances of the entire massive monolith memory footprint.
  • Deployment Bottleneck: Teams cannot ship independently; a single failed unit test blocks the entire company release.
๐Ÿ’ก Martin Fowler's Monolith First Principle

“You should not start a new project with microservices, even if you are certain your application will eventually be big enough to warrant one. Begin with a modular monolith, establish domain boundaries where change frequency is understood, and only extract services along clear seams.”

๐Ÿ’ฅ ANTI-PATTERN ALERT

2. The Fatal Anti-Pattern: The Big Bang Rewrite

When monoliths get unwieldy, leadership is often tempted by the fantasy of “The Big Bang Rewrite”: freeze monolith features, hire a team to build the entire system from scratch on Kubernetes, and flip the DNS switch on a glorious weekend.

Why Big Bang Rewrites Fail 85% of the Time:

1. The Moving Target Paradox: While Team B spends 18 months building the new microservices, Team A is still adding urgent business features and bug fixes to the legacy monolith. Team B can never achieve feature parity.

2. Undocumented Business Rules: Over 8 years, thousands of edge cases, compliance patches, and silent heuristics were baked into the monolith code. A clean rewrite inevitably breaks silent assumptions.

3. The Cutover Catastrophe: Attempting to switch 100% of live production traffic to an untested constellation of 40 new microservices produces cascading outages, data corruption, and immediate rollback.

The industry-proven alternative is gradual, non-breaking evolutionary migration using the Strangler Fig Pattern.

๐ŸŒฟ DESIGN PATTERN IN-DEPTH

3. Martin Fowler's Strangler Fig Pattern

The pattern takes its name from the Australian Strangler Fig tree. Its seeds germinate in the upper branches of a host tree. As it grows, it sends roots down to the forest floor. Gradually, the fig tree envelops the host, absorbs all incoming nutrients and sunlight, until the original host tree rots away peacefully inside, leaving a standalone fig tree.

4-Stage Strangler Fig Evolutionary Lifecycle
STAGE 1: INTERCEPTAPI Gateway ProxyMonolithUsers ยท Auth ยท CartOrders ยท PaymentsNotifications100% TrafficSTAGE 2: EXTRACTAPI Gateway (Route)MonolithCore App95% TrafficNotifsSvc5% TrafficSTAGE 3: STRANGLEAPI Gateway (Mesh)LegacyCore15%Microservices FleetUsers & Auth (100%)Payments (100%)Notifs (100%)85% TrafficSTAGE 4: DECOMPOSEAPI Gateway / EnvoyComplete MicroservicesUsers ยท Auth ยท OrdersPayments ยท InventoryCart ยท NotificationsMonolith Decommissioned100% Microservices

Step 1: Place an Interception Proxy / API Gateway

Before modifying a single line of application code, insert a reverse proxy (e.g. Envoy, Nginx, or AWS API Gateway) in front of the monolith. Direct 100% of incoming domain traffic through the gateway. At this stage, the gateway merely forwards all calls to the monolith without altering payloads.

Step 2: Carve Out an Edge Bounded Context

Never extract the central “Orders” or “Users” domain first! Pick an isolated, peripheral capability with minimal database dependencies — such as the Notification Service, Image Resizer, or PDF Generator. Build it as an independent service with its own CI/CD pipeline.

Step 3: Route Traffic via Canary Percentage Splits

Configure the API Gateway to route specific endpoint calls (e.g. POST /api/v1/notifications/send) to the new microservice for 1% of internal beta traffic, then 10%, 50%, and finally 100%. If an anomaly occurs, rollback to the monolith instantaneously via a single gateway route weight adjustment.

Step 4: Delete the Dead Monolith Code Path

Once the microservice operates reliably for several weeks under full production load, permanently remove the old code path and database tables from the monolith. Repeat for the next domain seam.

๐Ÿ’พ DATA ARCHITECTURE ยท DISTRIBUTED TRANSACTIONS

4. Database Decomposition & Anti-Corruption Layers

Extracting application code into Docker containers is the easy part. Decoupling the database is where 90% of migrations struggle. If your 15 new microservices all connect directly to the legacy shared PostgreSQL instance, you haven't built microservices — you've built a Distributed Monolith with added network latency and zero schema independence.

Anti-Corruption Layer (ACL)

When the new microservice needs data from the legacy monolith (or vice versa), never let the legacy monolith's convoluted database schema pollute the new service's clean domain model.

An Anti-Corruption Layer (ACL) acts as a bidirectional protocol translator. It converts legacy SOAP/SQL structures into pristine Domain-Driven Design (DDD) entities, shielding the new microservice from technical debt.

Change Data Capture (CDC) via Debezium

To migrate live tables without downtime, avoid dual writes from application code (which cause race conditions and partial failures).

Instead, use Change Data Capture (CDC) via tools like Debezium. Debezium reads the PostgreSQL WAL (Write-Ahead Log) or MySQL binlog in real-time, streaming row inserts and updates into Apache Kafka, asynchronously backfilling the new microservice database.

๐Ÿงช LIVE ARCHITECTURE LAB

5. Interactive Strangler Fig Migration Simulator

Select an enterprise domain to extract from the Monolith. Adjust the API Gateway traffic split slider to observe latency shifts, blast radius reduction, and live database synchronization.

Monolith Load Share
90%
Single Deployment Unit
Microservice Load Share
10%
Isolated Container Fleet
Blast Radius Exposure
Medium
Canary Rollout Active
LIVE ROUTING LOGS:CDC Replication Latency: 4ms
[00:00:01] Gateway received POST /api/v1/notify → Routing 10% to Microservice cluster (Pod 172.24.1.8)
[00:00:02] Anti-Corruption Layer translated LegacyPayload → NotificationRequestDomainEntity in 1.2ms
[00:00:03] Monolith PostgreSQL WAL stream → Debezium CDC → Kafka topic 'notifications.events' (Sync OK)
๐Ÿ’ป PRODUCTION PATTERNS ยท CODE TEMPLATES

6. Strangler Gateway & ACL Implementation

Below is an implementation of an Anti-Corruption Layer (ACL) and Canary Traffic Router demonstrating how incoming requests are safely intercepted, translated, and routed between legacy and modern services.

AntiCorruptionLayer.adapter
import random
import httpx
from typing import Dict, Any

class AntiCorruptionLayer:
    """Translates legacy monolith schema into clean modern domain entities."""
    @staticmethod
    def to_modern_payment(legacy_data: Dict[str, Any]) -> Dict[str, Any]:
        return {
            "payment_id": str(legacy_data.get("TXN_ID_NUM")),
            "customer_id": f"usr_{legacy_data.get('CUST_ID')}",
            "amount_in_cents": int(float(legacy_data.get("TOTAL_AMT", 0)) * 100),
            "currency": legacy_data.get("CURR_CODE", "USD").upper(),
            "status": "COMPLETED" if legacy_data.get("STATUS_FLG") == 1 else "FAILED"
        }

class StranglerGatewayRouter:
    def __init__(self, monolith_url: str, microservice_url: str, canary_percent: int = 10):
        self.monolith_url = monolith_url
        self.microservice_url = microservice_url
        self.canary_percent = canary_percent

    async def route_payment_request(self, payload: Dict[str, Any]) -> Dict[str, Any]:
        # Canary decision: route percentage of traffic to microservice
        if random.randint(1, 100) <= self.canary_percent:
            modern_payload = AntiCorruptionLayer.to_modern_payment(payload)
            async with httpx.AsyncClient() as client:
                res = await client.post(f"{self.microservice_url}/v1/payments", json=modern_payload)
                return {"source": "microservice", "data": res.json()}
        else:
            # Route to legacy monolith
            async with httpx.AsyncClient() as client:
                res = await client.post(f"{self.monolith_url}/legacy/process_payment.php", json=payload)
                return {"source": "monolith", "data": res.json()}
        
interface LegacyPayload {
  TXN_ID_NUM: number;
  CUST_ID: number;
  TOTAL_AMT: string;
  CURR_CODE: string;
  STATUS_FLG: number;
}

interface ModernPaymentEntity {
  paymentId: string;
  customerId: string;
  amountInCents: number;
  currency: string;
  status: 'COMPLETED' | 'FAILED';
}

export class AntiCorruptionLayer {
  static toModernPayment(legacy: LegacyPayload): ModernPaymentEntity {
    return {
      paymentId: String(legacy.TXN_ID_NUM),
      customerId: `usr_${legacy.CUST_ID}`,
      amountInCents: Math.round(parseFloat(legacy.TOTAL_AMT) * 100),
      currency: legacy.CURR_CODE.toUpperCase(),
      status: legacy.STATUS_FLG === 1 ? 'COMPLETED' : 'FAILED',
    };
  }
}

export class StranglerRouter {
  constructor(
    private monolithUrl: string,
    private microserviceUrl: string,
    private canaryPercent: number = 10
  ) {}

  async handlePayment(payload: LegacyPayload) {
    const isCanary = Math.random() * 100 <= this.canaryPercent;
    if (isCanary) {
      const modernData = AntiCorruptionLayer.toModernPayment(payload);
      const res = await fetch(`${this.microserviceUrl}/v1/payments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(modernData),
      });
      return { source: 'microservice', data: await res.json() };
    }

    const res = await fetch(`${this.monolithUrl}/legacy/payment`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    return { source: 'monolith', data: await res.json() };
  }
}
        
package main

import (
	"fmt"
	"math/rand"
	"strconv"
)

type LegacyPayment struct {
	TxnIDNum int     `json:"TXN_ID_NUM"`
	CustID   int     `json:"CUST_ID"`
	TotalAmt float64 `json:"TOTAL_AMT"`
	CurrCode string  `json:"CURR_CODE"`
	StatusFlg int    `json:"STATUS_FLG"`
}

type ModernPayment struct {
	PaymentID     string `json:"payment_id"`
	CustomerID    string `json:"customer_id"`
	AmountInCents int64  `json:"amount_in_cents"`
	Currency      string `json:"currency"`
	Status        string `json:"status"`
}

// AntiCorruptionLayer transforms legacy schema to modern domain
func TranslateToModern(legacy LegacyPayment) ModernPayment {
	status := "FAILED"
	if legacy.StatusFlg == 1 {
		status = "COMPLETED"
	}
	return ModernPayment{
		PaymentID:     strconv.Itoa(legacy.TxnIDNum),
		CustomerID:    fmt.Sprintf("usr_%d", legacy.CustID),
		AmountInCents: int64(legacy.TotalAmt * 100),
		Currency:      legacy.CurrCode,
		Status:        status,
	}
}

func RouteTraffic(legacy LegacyPayment, canaryPercent int) string {
	if rand.Intn(100) < canaryPercent {
		modern := TranslateToModern(legacy)
		return fmt.Sprintf("Routing to Microservice: %+v", modern)
	}
	return fmt.Sprintf("Routing to Monolith: %+v", legacy)
}
        
public class AntiCorruptionLayer {
    public record LegacyPayment(long TXN_ID_NUM, long CUST_ID, double TOTAL_AMT, String CURR_CODE, int STATUS_FLG) {}
    public record ModernPayment(String paymentId, String customerId, long amountInCents, String currency, String status) {}

    public static ModernPayment translate(LegacyPayment legacy) {
        String status = legacy.STATUS_FLG() == 1 ? "COMPLETED" : "FAILED";
        return new ModernPayment(
            String.valueOf(legacy.TXN_ID_NUM()),
            "usr_" + legacy.CUST_ID(),
            Math.round(legacy.TOTAL_AMT() * 100),
            legacy.CURR_CODE().toUpperCase(),
            status
        );
    }
}
        
#include <iostream>
#include <string>
#include <cmath>

struct LegacyPayment {
    int64_t TXN_ID_NUM;
    int64_t CUST_ID;
    double TOTAL_AMT;
    std::string CURR_CODE;
    int STATUS_FLG;
};

struct ModernPayment {
    std::string payment_id;
    std::string customer_id;
    int64_t amount_in_cents;
    std::string currency;
    std::string status;
};

class AntiCorruptionLayer {
public:
    static ModernPayment translate(const LegacyPayment& l) {
        return ModernPayment{
            std::to_string(l.TXN_ID_NUM),
            "usr_" + std::to_string(l.CUST_ID),
            static_cast<int64_t>(std::round(l.TOTAL_AMT * 100)),
            l.CURR_CODE,
            (l.STATUS_FLG == 1) ? "COMPLETED" : "FAILED"
        };
    }
};
        
โš–๏ธ PRODUCTION TRADEOFFS

7. The Microservices Operational Tax

Microservices solve organizational scaling problems (enabling 50 independent teams to ship code simultaneously). However, they introduce significant technical complexity — known as the Microservices Operational Tax:

1. Network Fallibility

In a monolith, function calls never fail due to DNS errors, socket timeouts, or router packet drops. In microservices, every remote call requires Circuit Breakers and retries.

2. Distributed Sagas

No more multi-table SQL BEGIN TRANSACTION ... COMMIT. Multi-service actions require complex Saga orchestration with compensating rollback steps.

3. Observability Tax

Tracking a single bug requires W3C distributed trace context propagation, OpenTelemetry spans, centralized log aggregators (ELK), and service mesh infrastructure.

โœ… The Microservices Readiness Checklist (Do NOT Migrate Unless You Have):

1. Automated CI/CD with 10-minute deployment cycles
2. Centralized metrics & distributed tracing (Otel)
3. Clear domain boundaries (no spaghetti coupling)
4. Multi-team organizational pain in the monolith
5. Infrastructure automation (IaC / Kubernetes)
6. Strict API versioning & contract testing
๐ŸŽฏ INTERVIEW SCENARIOS

8. Architectural Trade-off Quizzes

Scenario 1: A team wants to migrate their legacy monolithic e-commerce application to microservices. Which domain should they extract first?

Scenario 2: When extracting a service from a monolith, why is an Anti-Corruption Layer (ACL) recommended?

โ—‹

Completed this module?

Mark as finished to update your curriculum progress tracker.