Architecture

Decomposing the Monolith: The Strangler Fig Pattern in Production Without Distributed Transaction Disasters

HM
Hamza MalikPrincipal Systems Architect·2026-01-08·8 min read
VERIFIED BENCHMARK

The siren song of microservices has wrecked countless engineering teams. A startup with 12 engineers looks at Netflix or Uber and decides that their monolithic Rails, Django, or Express backend is "unscalable." They break the monolith into 25 independent services, only to discover that they have traded a manageable monolithic codebase for a distributed monolith with network latency, asynchronous race conditions, and unresolvable cascading outages.

At Codedway, we guide enterprise clients through monolith-to-services transitions only when genuine organizational or throughput scaling boundaries demand it.

When decomposition is truly necessary, the only proven, low-risk architectural pattern is the Strangler Fig Pattern. Here is how to execute this migration without downtime, data corruption, or distributed transaction chaos.

[CONSTRAINT]The Distributed Monolith Warning

If changing a single feature requires coordinated pull requests across three microservices, you do not have microservices. You have an in-process monolith chopped into pieces and connected over fragile HTTP sockets.


1. When to Decompose: The Three Valid Criteria

Never decompose a monolith merely for aesthetic reasons. There are only three legitimate reasons to extract a service:

  1. Disproportionate Compute / Scaling Profiles: 95% of your codebase is standard CRUD, but one module handles heavy video transcoding, vector search, or financial ledger calculations that requires specialized GPU or memory-heavy infrastructure.
  2. Team Autonomy at Scale: When your engineering organization exceeds 60+ engineers, merge conflicts on the monolith become a bottleneck. Independent service boundaries allow specialized teams to ship autonomously without stepping on common deployment locks.
  3. Fault Isolation Requirements: If the customer PDF generation service crashes with an Out-of-Memory (OOM) error, it must not bring down the checkout and payment processing flow.
       [ MONOLITHIC LEGACY APPLICATION ]
                       │
       ┌───────────────┴───────────────┐
       ▼                               ▼
 [ CHECKOUT & BILLING ]     [ HEAVY PDF GENERATION ]
 (High business impact,     (Memory leak hazard,
  stable CPU profile)        isolated failure domain)

2. The Strangler Fig Pattern Architecture

The Strangler Fig pattern (named by Martin Fowler after the Australian vines that envelop and gradually replace host trees) works by placing an API Gateway or Reverse Proxy (e.g. Envoy, NGINX, or Cloudflare) in front of the legacy monolith.

New capabilities are built strictly as new standalone microservices. Legacy endpoints are intercepted and migrated one route at a time.

                     [ INCOMING HTTP CLIENT ]
                                 │
                                 ▼
                     [ REVERSE PROXY / ENVOY ]
                                 │
           ┌─────────────────────┴─────────────────────┐
           ▼                                           ▼
 [ LEGACY MONOLITH ]                       [ EXTRACTED SERVICE (NEW) ]
  • /api/v1/auth                            • /api/v1/billing
  • /api/v1/orders                          • /api/v1/invoices
  • /api/v1/inventory (Migrating)

Phase 1: Intercept & Shadow Routing

Before shifting live traffic, configure the proxy to duplicate incoming requests to the new service in "shadow mode." The response from the legacy monolith is returned to the client, while the response from the new service is compared asynchronously in a background worker to detect regressions.

Phase 2: Canary Cutover

Route 5% of non-critical traffic to the new service using feature flags. Monitor P99 latency, error rates, and database read replica load. Gradually ramp to 25%, 50%, and 100%.

Phase 3: Strangling the Old Module

Once 100% of traffic is served by the new service, delete the legacy code and its database tables from the monolith.


3. The Hardest Problem: Decoupling the Database

Extracting business logic is easy; extracting data models without breaking transactional integrity is where 90% of migrations fail.

In a monolith, code relies on ACID transactions and relational joins:

BEGIN;
  INSERT INTO orders (...) VALUES (...);
  UPDATE inventory SET stock = stock - 1 WHERE item_id = 42;
COMMIT;

Once Inventory is moved to a separate microservice with its own database, you cannot execute a cross-database foreign key join or atomic transaction without fragile distributed two-phase commits (2PC).

The Solution: The Transactional Outbox Pattern

Instead of direct database coupling or dual-writes (writing to the database and publishing to Kafka sequentially, which inevitably fails when one crashes), use the Transactional Outbox Pattern:

  1. Within the local database transaction, write your business mutation AND write an event record into a dedicated outbox table.
  2. A lightweight background CDC (Change Data Capture) process (like Debezium or a polling worker) reads the outbox and publishes the event to Kafka or RabbitMQ.
-- Executed inside a single atomic local PostgreSQL transaction
BEGIN;

  -- 1. Insert order into local domain table
  INSERT INTO orders (id, customer_id, total_amount, status)
  VALUES ('ord_9941', 'cust_12', 450.00, 'PENDING');

  -- 2. Insert event into outbox table (Guaranteed atomicity)
  INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
  VALUES (
    gen_random_uuid(),
    'ORDER',
    'ord_9941',
    'ORDER_CREATED',
    jsonb_build_object(
      'order_id', 'ord_9941',
      'customer_id', 'cust_12',
      'items', jsonb_build_array(jsonb_build_object('sku', 'WIDGET-01', 'qty', 2))
    )
  );

COMMIT;

Production Outbox Publisher in Go

package outbox

import (
	"context"
	"database/sql"
	"encoding/json"
	"time"

	"github.com/segmentio/kafka-go"
)

type OutboxWorker struct {
	db          *sql.DB
	kafkaWriter *kafka.Writer
}

func (w *OutboxWorker) PollAndPublish(ctx context.Context) error {
	tx, err := w.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Select un-published events with row lock to prevent race conditions
	rows, err := tx.QueryContext(ctx, `
		SELECT id, event_type, payload 
		FROM outbox_events 
		WHERE published_at IS NULL 
		ORDER BY created_at ASC 
		LIMIT 50 
		FOR UPDATE SKIP LOCKED
	`)
	if err != nil {
		return err
	}
	defer rows.Close()

	var publishedIDs []string

	for rows.Next() {
		var id, eventType string
		var rawPayload []byte

		if err := rows.Scan(&id, &eventType, &rawPayload); err != nil {
			return err
		}

		// Publish to Kafka topic
		err = w.kafkaWriter.WriteMessages(ctx, kafka.Message{
			Key:   []byte(eventType),
			Value: rawPayload,
			Time:  time.Now(),
		})
		if err != nil {
			return err // Will roll back, event remains safely in outbox
		}

		publishedIDs = append(publishedIDs, id)
	}

	// Mark events as published
	for _, pubID := range publishedIDs {
		_, err := tx.ExecContext(ctx, `
			UPDATE outbox_events 
			SET published_at = NOW() 
			WHERE id = $1
		`, pubID)
		if err != nil {
			return err
		}
	}

	return tx.Commit()
}
0 DowntimePRODUCTION CUTOVER SLA

Maintained across 8-month migration extracting billing and telemetry domains while processing 12M monthly transactions.


4. Key Lessons from 20+ Enterprise Migrations

  1. Start with the Leaf Nodes: Never extract your core User or Authentication service first. Start with edge services that have zero downstream dependencies — such as email notifications, PDF generation, or audit logging.
  2. Accept Eventual Consistency: Once you split services, data is eventually consistent. Design user interfaces with optimistic UI states and status indicators (Status: Processing) rather than expecting synchronous confirmations.
  3. Invest in Distributed Tracing First: Before extracting your first microservice, instrument OpenTelemetry across your entire monolith. If you cannot trace a single HTTP request across network hops with a distributed trace ID, you will be blind when debugging production anomalies.

Monolith decomposition is a surgery that carries real architectural risk. By applying the Strangler Fig pattern with transactional outboxes, you transform an existential rebuild into a disciplined, reversible engineering evolution.

HM

Hamza Malik

Principal Systems Architect

Senior engineering practitioner at Codedway. Specializing in fault-tolerant systems architecture, distributed database topologies, and deterministic runtime reliability.

→ linkedin.com/in/hamzamalik