Pavan Rangani

HomeBlogStrangler Fig Pattern: Safely Migrating from Monolith to Microservices

Strangler Fig Pattern: Safely Migrating from Monolith to Microservices

By Pavan Rangani · March 26, 2026 · Architecture

Strangler Fig Pattern: Safely Migrating from Monolith to Microservices

Strangler Fig Migration from Monolith to Microservices

Strangler fig migration is the safest approach to decomposing a monolith into microservices. Named after the strangler fig tree that gradually envelops and replaces its host tree, this pattern incrementally routes functionality from the monolith to new services. At no point does the system require a big-bang cutover — the monolith continues to handle existing features while new services take over piece by piece.

This guide provides a practical implementation plan covering request routing, feature extraction, data migration, and rollback strategies. Whether your monolith is 5 years or 15 years old, the pattern provides a risk-managed path to a microservices architecture. Throughout, the emphasis is on keeping a working system at every step, because the entire value of the approach evaporates the moment you let the two systems drift out of parity.

Why Strangler Fig Over Big-Bang Rewrite

Big-bang rewrites fail at high rates according to widely cited industry surveys, in part because they freeze feature development for months while the business keeps moving. The strangler fig pattern succeeds because it delivers value incrementally, maintains a working system at every step, and allows rollback if problems occur. Moreover, the team learns microservices patterns on low-risk extractions before tackling complex domain boundaries.

Strangler fig pattern architecture diagram
The strangler fig pattern gradually routes traffic from monolith to new microservices
Strangler Fig Migration Timeline

Phase 1 (Month 1-2):
[Monolith: 100%] ←──── All traffic
[Facade/Proxy]

Phase 2 (Month 3-6):
[Monolith: 80%]  ←──── Most traffic
[Service A: 10%] ←──── Extracted feature
[Service B: 10%] ←──── Extracted feature
[Facade/Proxy]   ←──── Routes by path

Phase 3 (Month 7-12):
[Monolith: 30%]  ←──── Legacy only
[Service A: 15%]
[Service B: 15%]
[Service C: 20%]
[Service D: 20%]
[Facade/Proxy]

Phase 4 (Month 12-18):
[Monolith: 0%]   ←──── Decommissioned
[Microservices: 100%]

Choosing the First Service to Extract

The single biggest predictor of a successful migration is the order in which you peel off functionality. A common and well-documented heuristic is to start with a bounded context that is loosely coupled, low-risk, and read-heavy — something like notifications, search, or a reporting endpoint — rather than the order-and-payment core that touches everything. Early extractions exist as much to build team confidence and tooling as to deliver business value.

Conversely, the worst first choice is a deeply entangled module with many synchronous callers and shared mutable state. Untangling those dependencies before you have a working facade, observability, and a rollback drill is how migrations stall. Map the dependency graph first, then attack the leaves, working inward only once the pattern is proven on safe ground.

Setting Up the Strangler Facade

The facade is a reverse proxy that routes requests either to the monolith or to new services. Additionally, it enables gradual traffic shifting and A/B testing during migration. Crucially, the facade should be introduced before you extract anything, so that all traffic already flows through a layer you control and the first extraction becomes a routing change rather than a client change.

# nginx.conf — Strangler facade configuration
upstream monolith {
    server monolith.internal:8080;
}
upstream user_service {
    server user-service.internal:8080;
}
upstream order_service {
    server order-service.internal:8080;
}

# Feature flags for gradual migration
map $cookie_migration_flags $use_new_user_service {
    "~*user_v2"  1;
    default      0;
}

server {
    listen 443 ssl;

    # Extracted: User service (fully migrated)
    location /api/users {
        proxy_pass http://user_service;
    }

    # In progress: Order service (canary)
    location /api/orders {
        # Route 10% of traffic to new service
        split_clients "${remote_addr}" $order_backend {
            10%    order_service;
            *      monolith;
        }
        proxy_pass http://$order_backend;
    }

    # Not yet migrated: everything else
    location / {
        proxy_pass http://monolith;
    }
}

Programmatic Routing with Feature Flags

For routing decisions that depend on business context — a user’s tenant, their plan tier, or an internal beta flag — a proxy config is too blunt. A programmatic facade inside the application gives you fine-grained control and, critically, a fallback path. The example below routes to the new service when a flag is enabled but falls straight back to the monolith on any failure, so a bug in the new service degrades gracefully instead of taking down the endpoint.

@RestController
@RequestMapping("/api")
public class StranglerFacadeController {

    private final MonolithClient monolith;
    private final UserServiceClient userService;
    private final FeatureFlagService flags;

    @GetMapping("/users/{id}")
    public ResponseEntity<?> getUser(
            @PathVariable String id,
            HttpServletRequest request) {

        if (flags.isEnabled("user-service-v2",
                extractContext(request))) {
            try {
                var user = userService.getUser(id);
                return ResponseEntity.ok(user);
            } catch (Exception e) {
                log.error("New service failed, falling back", e);
                metrics.increment("strangler.fallback.user");
                // Fallback to monolith on failure
                return monolith.forwardRequest(request);
            }
        }

        return monolith.forwardRequest(request);
    }
}

That metrics.increment("strangler.fallback.user") line is doing quiet but essential work. A rising fallback rate is your early-warning signal that the new service is misbehaving under real traffic, and it lets you alert and roll back via the flag long before users notice. Treat the fallback counter as a first-class SLO during any canary phase.

Data Migration Strategies

Data extraction is the hardest part of the pattern, and it is where most migrations actually fail. The application code can be split cleanly, but a shared database silently re-couples the services unless you have a deliberate plan. Therefore, choose a strategy based on your consistency requirements and accept that some intermediate coupling is unavoidable.

Data Migration Strategies

1. Shared Database (Simplest, temporary)
   Monolith + New Service → Same DB
   Pro: No data sync needed
   Con: Couples services via schema

2. Database View (Medium complexity)
   Monolith DB → Views → New Service reads
   Pro: Read isolation without data copy
   Con: Write still goes to monolith

3. Change Data Capture (Recommended)
   Monolith DB → Debezium → Kafka → New Service DB
   Pro: Real-time sync, decoupled
   Con: Eventual consistency

4. Dual Write (Risky, avoid if possible)
   Write to both DBs simultaneously
   Pro: Real-time consistency
   Con: Distributed transaction problems

Change Data Capture is the strategy most production teams converge on, because it lets the new service own its own database while staying in sync with the monolith without code changes in the legacy system. Debezium tails the database’s write-ahead log and streams every insert, update, and delete onto Kafka, where the new service consumes and materializes its own view. The trade-off is eventual consistency — the new store lags the source by milliseconds to seconds — which is acceptable for the vast majority of read paths but must be designed around for read-your-own-writes flows.

# Debezium connector for CDC-based data migration
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnector
metadata:
  name: monolith-user-cdc
spec:
  class: io.debezium.connector.postgresql.PostgresConnector
  config:
    database.hostname: monolith-db.internal
    database.port: "5432"
    database.user: debezium
    database.password: "${file:/secrets/db-password}"
    database.dbname: monolith
    table.include.list: "public.users,public.user_profiles"
    topic.prefix: "monolith.cdc"
    plugin.name: pgoutput
    slot.name: user_migration
    publication.name: user_tables
    transforms: route
    transforms.route.type: org.apache.kafka.connect.transforms.RegexRouter
    transforms.route.regex: "monolith\.cdc\.public\.(.*)"
    transforms.route.replacement: "user-service.migration.$1"

One operational caveat deserves emphasis: the Postgres logical replication slot named in slot.name retains write-ahead log segments until the connector consumes them. If the connector stops and nobody notices, the source database’s disk fills with retained WAL and can take the monolith down — an outage caused by the migration tooling itself. Monitor replication slot lag as carefully as you monitor the services. For more on the messaging backbone underneath, see our guide on event-driven microservices with Kafka.

Data migration from monolith to microservices with CDC
Change Data Capture provides real-time data synchronization during migration

Verification and Rollback

Every extraction must include verification. Consequently, run the monolith and new service in parallel and compare results — a technique often called shadow traffic or dark launching. The new service receives a copy of real requests, its responses are compared against the monolith’s, and discrepancies are logged without ever affecting the user, who continues to be served by the proven path.

class ParallelVerifier:
    """Compare monolith and new service responses."""

    def __init__(self, monolith_url, service_url):
        self.monolith = monolith_url
        self.service = service_url
        self.mismatches = []

    async def verify_endpoint(self, path, method="GET"):
        """Call both services and compare responses."""
        mono_resp = await self.call(self.monolith + path, method)
        svc_resp = await self.call(self.service + path, method)

        if not self.responses_match(mono_resp, svc_resp):
            self.mismatches.append({
                "path": path,
                "monolith": mono_resp,
                "service": svc_resp,
                "timestamp": datetime.now().isoformat(),
            })
            return False
        return True

    def responses_match(self, a, b):
        """Compare responses, ignoring non-significant fields."""
        ignore_fields = ["timestamp", "requestId", "version"]
        a_clean = {k: v for k, v in a.items()
                   if k not in ignore_fields}
        b_clean = {k: v for k, v in b.items()
                   if k not in ignore_fields}
        return a_clean == b_clean

The ignore_fields list is the part that turns a noisy comparison into a useful one. Timestamps, request IDs, and version stamps legitimately differ between the two systems, and comparing them naively buries the real defects under a flood of false mismatches. Once shadow comparison reports a near-zero mismatch rate over a representative window of traffic, you have earned the confidence to flip the flag and route live traffic to the new service.

When NOT to Use Strangler Fig

The pattern assumes a request-response architecture with clear API boundaries. If your monolith is a batch processing system with complex data pipelines and no HTTP endpoints, alternative decomposition strategies may be more appropriate, because there is no request stream for a facade to intercept. Additionally, if the monolith will be completely rewritten in a different technology stack — for example, moving from COBOL to Java — the overhead of maintaining two systems during migration may not be justified.

There is also a cost-versus-payoff line worth naming honestly. For very small monoliths that a team can rewrite in two to three months, the incremental approach adds facade infrastructure, dual-running cost, and CDC complexity that a focused rewrite simply avoids. The pattern earns its keep precisely when the system is too large, too critical, or too actively developed to pause — and it becomes overhead when the system is none of those things.

Architecture migration decision framework
Evaluate the monolith’s architecture before choosing a decomposition strategy

Key Takeaways

  • The strangler fig pattern eliminates the risk of big-bang rewrites by extracting services incrementally while keeping a working system throughout
  • Introduce the facade first; a reverse proxy or programmatic router shifts traffic by path, percentage, or feature flag with a fallback to the monolith
  • Change Data Capture with Debezium is the safest data strategy, but you must monitor replication slot lag to avoid disk-fill outages
  • Shadow-traffic verification proves functional parity before you cut over, and a fallback counter is your live early-warning signal
  • Start with the simplest, most isolated bounded context to build team confidence before tackling the entangled core

Related Reading

External Resources

In conclusion, a well-executed strangler fig migration is an essential capability for modernizing legacy systems without betting the business on a single cutover. By applying the facade, data, and verification practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, extract the safest bounded context first, and continuously measure parity to ensure you are getting the most value from these approaches.

← Back to all articles