How to Decompose a Monolith into Microservices

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

Quick Reference

Decomposition PatternWhen to UseRisk LevelEffortKey Requirement
Strangler FigIncremental migration of any monolithLowMediumAPI gateway or proxy to route traffic
Branch by AbstractionExtract internal modules without cutting APILowMediumAbstraction layer in monolith codebase
Parallel RunValidate new service produces identical resultsLowHighDual-write + comparison pipeline
Decompose by Business CapabilityClear business functions map to servicesMediumMediumBusiness domain expertise
Decompose by Subdomain (DDD)Complex domain with many bounded contextsMediumHighDomain modeling workshops
Database-per-ServiceEliminate shared DB couplingHighVery HighData migration strategy + eventual consistency
Change Data Capture (CDC)Sync data during transition without dual-writeMediumMediumCDC tool (Debezium, DynamoDB Streams)
Anti-Corruption LayerIsolate new service from legacy data modelLowMediumTranslation layer between old/new models
Modular Monolith (intermediate)Not ready for network boundaries yetVery LowLowClean module boundaries within monolith
Saga PatternDistributed transactions across servicesHighHighEvent choreography or orchestrator

Decision Tree

START
├── Do you have clear, measurable reasons to move to microservices?
│   ├── NO → Stop. A modular monolith may be sufficient. [src2, src8]
│   └── YES ↓
├── How many developers on the codebase?
│   ├── <10 → Strongly consider modular monolith instead. [src8]
│   └── ≥10 ↓
├── Is this a legacy system with unclear domain boundaries?
│   ├── YES → Run domain modeling / Event Storming workshops first [src7]
│   └── NO ↓
├── Can you place a proxy/gateway in front of the monolith?
│   ├── YES → Use Strangler Fig pattern (recommended default) [src1, src3]
│   └── NO → Use Branch by Abstraction inside the codebase [src2]
├── Does the monolith use a single shared database?
│   ├── YES → Plan database decomposition (hardest part) — start with schema separation [src2, src6]
│   └── NO ↓
├── Are there cross-cutting concerns (auth, logging, config)?
│   ├── YES → Extract these as shared platform services first [src5]
│   └── NO ↓
├── How many concurrent users?
│   ├── <1K → Start with 2-3 services, expand as needed
│   ├── 1K-100K → Containerize with Docker + Kubernetes [src5]
│   └── >100K → Full service mesh + event-driven architecture [src5]
└── DEFAULT → Extract one bounded context at a time, validate, repeat

Step-by-Step Guide

1. Define business objectives before touching code

Articulate exactly why you need microservices: independent deployability, team autonomy, targeted scaling, or polyglot tech stacks. If you can't name a specific pain point the monolith causes, you probably don't need microservices yet. As of 2025-2026, cost (not scalability) has become the primary architectural constraint — microservices infrastructure costs 3.75x-6x more than equivalent monoliths. [src2, src8]

# Questions to answer before starting:
1. What specific scaling bottleneck does the monolith have?
2. Which teams block each other during deployment?
3. What is the expected deployment frequency per service?
4. What is the acceptable latency budget for inter-service calls?
5. Do you have the operational maturity for distributed systems?
6. What is the infrastructure budget delta (monolith vs microservices)?

Verify: Document answers. If most answers are vague, invest in a modular monolith first.

2. Map bounded contexts with Event Storming

Use Event Storming (or similar domain modeling technique) to identify bounded contexts — areas of the domain with distinct language, rules, and data ownership. Each bounded context is a candidate microservice. AI-assisted tools like vFunction can now automate much of this analysis. [src7, src2]

# Event Storming output → bounded context candidates
Domain Events:
  [Order Placed] → Order context
  [Payment Processed] → Payment context
  [Item Shipped] → Shipping context
  [Inventory Reserved] → Inventory context

Bounded Contexts identified:
  1. Orders (owns: order data, order lifecycle)
  2. Payments (owns: payment data, refunds)
  3. Shipping (owns: shipment tracking, carrier integration)
  4. Inventory (owns: stock levels, reservations)
  5. Catalog (owns: product data, pricing)

Verify: Each context has a clear owner (team), distinct data, and minimal cross-context joins in the current database.

3. Enforce module boundaries in the monolith first

Before extracting any service across a network boundary, refactor the monolith into clean modules with internal APIs. This validates your bounded context boundaries cheaply. This intermediate step is now widely recommended as of 2025. [src2, src8]

# Enforce module boundaries in a Python monolith
# BEFORE: Tangled imports across domains
from orders.models import Order
from payments.models import Payment  # direct cross-domain import

# AFTER: Each module exposes a public API facade
# payments/api.py (public interface)
def get_payment_status(order_id: str) -> str:
    payment = Payment.objects.get(order_id=order_id)
    return payment.status

# orders/services.py (uses public API, not internal models)
from payments.api import get_payment_status
status = get_payment_status(order.id)

Verify: No module imports another module's internal models directly. All cross-module communication goes through public API facades.

4. Set up the Strangler Fig facade

Place an API gateway or reverse proxy in front of the monolith. Initially, all traffic routes to the monolith. As you extract services, update routing rules to direct specific paths to the new service. Modern implementations often use Istio or Envoy for traffic splitting with percentage-based rollout. [src1, src3]

# Example: nginx routing configuration for Strangler Fig
upstream monolith {
  server monolith-app:8080;
}
upstream orders_service {
  server orders-service:8081;
}

server {
  listen 80;

  # New service handles /api/orders/*
  location /api/orders/ {
    proxy_pass http://orders_service;
  }

  # Everything else still goes to the monolith
  location / {
    proxy_pass http://monolith;
  }
}

Verify: curl http://gateway/api/orders/123 returns response from new service; curl http://gateway/api/products still hits monolith.

5. Extract the first bounded context

Pick the context with the lowest coupling to the rest of the monolith. Extract it into a standalone service with its own repository, CI/CD pipeline, and data store. [src2, src6]

# Example: Extracting a Notifications service from a Django monolith
# BEFORE: Notification logic embedded in monolith
def place_order(request):
    order = Order.objects.create(...)
    send_email(order.user.email, "Order confirmed", ...)
    send_sms(order.user.phone, "Order confirmed")
    return JsonResponse({"order_id": order.id})

# AFTER: Monolith publishes event, Notification service consumes it
def place_order(request):
    order = Order.objects.create(...)
    publish_event("order.placed", {
        "order_id": order.id,
        "user_email": order.user.email,
        "user_phone": order.user.phone
    })
    return JsonResponse({"order_id": order.id})

Verify: Place an order via the monolith → event appears in message broker → notification service sends email/SMS.

6. Decompose the shared database

This is typically the hardest step. Start by separating schemas, then migrate to physically separate databases. Use Change Data Capture (CDC) or dual-write during transition. With 83% of data migrations failing or exceeding budgets, plan conservatively. [src2, src6]

-- Step 1: Schema separation within the same database
CREATE SCHEMA orders_service;
ALTER TABLE orders SET SCHEMA orders_service;
ALTER TABLE order_items SET SCHEMA orders_service;

-- Step 2: Replace cross-schema joins with API calls
-- BEFORE: SELECT o.*, p.name FROM orders o JOIN products p ON o.product_id = p.id;
-- AFTER: Orders service calls Catalog API: GET /api/catalog/products/123
# Step 3: Use Debezium CDC to sync data during transition
docker run -d --name debezium \
  -e BOOTSTRAP_SERVERS=kafka:9092 \
  -e GROUP_ID=1 \
  -e CONFIG_STORAGE_TOPIC=debezium-configs \
  debezium/connect:2.5

# Register a connector for the orders table
curl -X POST http://localhost:8083/connectors -H "Content-Type: application/json" -d '{
  "name": "orders-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "monolith-db",
    "database.dbname": "app",
    "table.include.list": "public.orders,public.order_items",
    "topic.prefix": "orders"
  }
}'

Verify: Changes to the orders table in the monolith DB appear as events in the orders.public.orders Kafka topic within seconds.

7. Implement observability before scaling

Before extracting more services, ensure you have centralized logging, distributed tracing, and health monitoring. OpenTelemetry has become the industry standard for instrumentation as of 2025. [src5, src6]

# docker-compose.yml — minimal observability stack
services:
  jaeger:
    image: jaegertracing/all-in-one:1.54
    ports:
      - "16686:16686"  # UI
      - "4318:4318"    # OTLP HTTP
  prometheus:
    image: prom/prometheus:v2.50.0
    ports:
      - "9090:9090"
  grafana:
    image: grafana/grafana:10.3.0
    ports:
      - "3000:3000"

Verify: Service traces visible in Jaeger. Metrics scraped by Prometheus. Dashboards rendering in Grafana.

8. Iterate: extract more bounded contexts

Repeat steps 5-6 for each bounded context, prioritizing by coupling pain and business value. Expect 6-18 months for a medium monolith (100K-500K LOC) to fully decompose. The 2025-2026 recommendation: modular monolith core plus 2-5 extracted services for hot paths. [src2, src4, src8]

Verify: Each extraction reduces the monolith's LOC by 10-20%. Deployment frequency per service should increase measurably.

Code Examples

Python/FastAPI: Strangler Fig with API Gateway routing

# Input:  A monolith where /api/orders is being extracted to a new service
# Output: A FastAPI gateway that routes between monolith and new services

from fastapi import FastAPI, Request, Response
import httpx

app = FastAPI()

ROUTES = {
    "/api/orders": "http://orders-service:8081",
    "/api/payments": "http://payments-service:8082",
}
MONOLITH_URL = "http://monolith:8080"

@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(request: Request, path: str):
    target_url = MONOLITH_URL
    for prefix, service_url in ROUTES.items():
        if f"/{path}".startswith(prefix):
            target_url = service_url
            break

    async with httpx.AsyncClient(timeout=30.0) as client:
        url = f"{target_url}/{path}"
        response = await client.request(
            method=request.method,
            url=url,
            headers={k: v for k, v in request.headers.items() if k.lower() != "host"},
            content=await request.body(),
            params=request.query_params,
        )

    return Response(
        content=response.content,
        status_code=response.status_code,
        headers=dict(response.headers),
    )

TypeScript/NestJS: Event-driven service extraction

// Input:  A bounded context extracted as a NestJS microservice
// Output: Service that consumes domain events from the monolith via RabbitMQ

import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { Module, Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';

@Controller()
class OrderEventController {
  @EventPattern('order.placed')
  async handleOrderPlaced(@Payload() data: {
    orderId: string;
    userId: string;
    items: Array<{ productId: string; quantity: number }>;
  }) {
    for (const item of data.items) {
      await this.reserveStock(item.productId, item.quantity);
    }
  }

  @EventPattern('order.cancelled')
  async handleOrderCancelled(@Payload() data: { orderId: string }) {
    await this.releaseReservation(data.orderId);
  }

  private async reserveStock(productId: string, qty: number): Promise<void> {
    // Update inventory in this service's own database
  }

  private async releaseReservation(orderId: string): Promise<void> {
    // Release previously reserved stock
  }
}

@Module({ controllers: [OrderEventController] })
class InventoryModule {}

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    InventoryModule,
    {
      transport: Transport.RMQ,
      options: {
        urls: [process.env.RABBITMQ_URL || 'amqp://localhost:5672'],
        queue: 'inventory_queue',
        queueOptions: { durable: true },
      },
    },
  );
  await app.listen();
}
bootstrap();

Go: Anti-Corruption Layer between monolith and new service

// Input:  Legacy monolith uses a different data model than the new service
// Output: ACL translates between legacy and new domain models

package acl

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type LegacyOrder struct {
	OrderNum   string `json:"order_num"`
	CustID     int    `json:"cust_id"`
	TotalCents int64  `json:"total_cents"`
	StatusCode int    `json:"status_code"` // 0=pending, 1=confirmed, 2=shipped
	CreatedTS  int64  `json:"created_ts"`
}

type Order struct {
	ID          string    `json:"id"`
	CustomerID  string    `json:"customer_id"`
	TotalAmount float64   `json:"total_amount"`
	Status      string    `json:"status"`
	CreatedAt   time.Time `json:"created_at"`
}

type AntiCorruptionLayer struct {
	monolithURL string
	client      *http.Client
}

func NewACL(monolithURL string) *AntiCorruptionLayer {
	return &AntiCorruptionLayer{
		monolithURL: monolithURL,
		client:      &http.Client{Timeout: 10 * time.Second},
	}
}

func (acl *AntiCorruptionLayer) GetOrder(ctx context.Context, orderID string) (*Order, error) {
	url := fmt.Sprintf("%s/legacy/orders/%s", acl.monolithURL, orderID)
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, fmt.Errorf("creating request: %w", err)
	}
	resp, err := acl.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetching from monolith: %w", err)
	}
	defer resp.Body.Close()

	var legacy LegacyOrder
	if err := json.NewDecoder(resp.Body).Decode(&legacy); err != nil {
		return nil, fmt.Errorf("decoding legacy response: %w", err)
	}
	return acl.translate(legacy), nil
}

func (acl *AntiCorruptionLayer) translate(legacy LegacyOrder) *Order {
	statusMap := map[int]string{0: "pending", 1: "confirmed", 2: "shipped"}
	return &Order{
		ID:          legacy.OrderNum,
		CustomerID:  fmt.Sprintf("cust-%d", legacy.CustID),
		TotalAmount: float64(legacy.TotalCents) / 100.0,
		Status:      statusMap[legacy.StatusCode],
		CreatedAt:   time.Unix(legacy.CreatedTS, 0),
	}
}

Anti-Patterns

Wrong: Big-bang rewrite

// BAD — Rewriting everything from scratch before deploying anything
// "We'll freeze features for 6 months while we rebuild in microservices"
// Result: project runs over schedule, new system has different bugs,
// migration day is chaos.

Correct: Incremental Strangler Fig migration

// GOOD — Extract one service at a time, deploy to production, validate, repeat
// Week 1-4: Extract Notifications service → deploy → monitor
// Week 5-8: Extract Auth service → deploy → monitor
// Week 9-12: Extract Payments service → deploy → monitor
// Monolith shrinks gradually. Zero "migration day." [src1, src2]

Wrong: Sharing a database across microservices

-- BAD — Multiple services reading/writing the same tables
-- orders-service: SELECT * FROM orders WHERE user_id = 123;
-- payments-service: UPDATE orders SET payment_status = 'paid' WHERE id = 456;
-- inventory-service: SELECT * FROM orders o JOIN inventory i ON ...;
-- All services coupled through shared schema.

Correct: Database-per-service with API calls

# GOOD — Each service owns its data; communicates via APIs or events
async def process_payment(payment_data):
    result = await charge_card(payment_data)
    if result.success:
        await publish_event("payment.completed", {
            "order_id": payment_data["order_id"],
            "amount": result.amount,
            "transaction_id": result.transaction_id
        })
    return result  # [src2, src6]

Wrong: Extracting services by technical layer

// BAD — Splitting by technical concern instead of business domain
//   "frontend-service" — all UI code
//   "api-service" — all API endpoints
//   "database-service" — all database operations
// Still tightly coupled — changing one feature touches all three.

Correct: Extract services by business capability

// GOOD — Each service owns a full vertical slice of functionality
//   "orders-service" — Orders API + Orders DB + Order business logic
//   "payments-service" — Payments API + Payments DB + Payment logic
//   "catalog-service" — Catalog API + Catalog DB + Product logic
// A change to order logic only touches orders-service. [src2, src7]

Wrong: Making services too small ("nanoservices")

// BAD — One service per CRUD operation
//   "create-order-service"
//   "get-order-service"
//   "update-order-status-service"
//   "delete-order-service"
// Operational overhead exceeds benefit.

Correct: Service boundaries match bounded contexts

// GOOD — Service encompasses a complete bounded context
//   "orders-service" handles: create, read, update, delete, status
//   transitions, order history, and order-related events.
//   One repo, one CI/CD pipeline, one team owns it. [src2, src4]

Wrong: Microservices for a small team (2025-2026 anti-pattern)

// BAD — 5-person startup adopting microservices "because Netflix does it"
// Infrastructure cost: $40K-65K/month vs $15K/month for equivalent monolith
// Result: 70% of engineering time spent on infrastructure, not features.
// Amazon Prime Video team moved BACK to monolith, cut costs by 90%. [src8]

Correct: Start modular, extract when justified

// GOOD — Build a modular monolith first, extract hot paths as needed
// Phase 1: Modular monolith with clean internal boundaries
// Phase 2: Extract 2-5 services for independently-scaling hot paths
// Phase 3: Continue extracting only when business value justifies overhead
// The 2025-2026 industry consensus for teams with 10-100 engineers. [src8]

Common Pitfalls

Diagnostic Commands

# Analyze coupling in a monolith codebase (Java)
jdeps --summary --multi-release 21 target/*.jar

# Analyze Python import dependencies
pydeps myapp --max-bacon 2 --cluster

# Count cross-module database joins
grep -rn 'JOIN' --include='*.py' --include='*.java' --include='*.ts' | wc -l

# Check for shared database tables across services
psql -c "SELECT schemaname, tablename, tableowner FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema');"

# Verify service independence — can each service start alone?
docker compose up orders-service --no-deps
curl http://localhost:8081/health

# Measure inter-service latency
curl -w "time_total: %{time_total}s\n" -o /dev/null -s http://orders-service/api/orders/123

# Verify database isolation — no cross-service schema access
psql -c "SELECT grantee, table_schema, table_name, privilege_type FROM information_schema.table_privileges WHERE grantee NOT IN ('postgres', 'PUBLIC') ORDER BY grantee, table_schema;"

Version History & Compatibility

ApproachStatusKey DevelopmentNotes
Strangler Fig (Fowler, 2004)Industry standardPattern matured with cloud-native adoptionStill the recommended starting point
DDD Bounded Contexts (Evans, 2003)Industry standardRefined by Newman (2019) for microservicesRequired for correct service boundaries
Saga Pattern (Garcia-Molina, 1987)Widely adoptedOrchestration vs. choreography variantsUse for distributed transactions
Modular Monolith (2020s trend)Growing rapidly (2025)Recommended intermediate step by 42% of orgsConsider before microservices
Event-Driven DecompositionCurrent best practiceKafka/RabbitMQ as decoupling mechanismEnables async communication
AI-Assisted Migration (2025+)EmergingvFunction, automated dependency analysisSaves hours of manual assessment
Hybrid Architecture (2025-2026)Industry consensusModular monolith core + extracted hot pathsBest of both worlds for mid-size teams

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Teams block each other during deploymentsSmall team (<10 developers)Modular monolith
Specific components need independent scalingSimple CRUD applicationWell-structured monolith
Different components need different tech stacksUnclear domain boundariesDDD modeling + modular monolith first
Regulatory compliance requires isolation (PCI, HIPAA)Startup still finding product-market fitMonolith (iterate faster)
>100 developers working on same codebaseBudget doesn't cover operational overhead (3.75x-6x)Vertical slice architecture
Deployment frequency needs vary per componentAll components have similar load patternsMonolith with horizontal scaling

Important Caveats

Related Units