00:00

Strangler Fig Pattern: Replace a Monolith One Piece at a Time

Last updated:

The strangler fig pattern is a way to replace an old system gradually: you put a routing layer in front of it, build new services one feature at a time, and send each feature's traffic to the new service once it is ready. The old system keeps running and shrinks until nothing is left and it can be switched off. There is never a risky "big bang" day when everything changes at once.

The name comes from the strangler fig, a tree that grows around a host tree until it can stand on its own. Martin Fowler described the idea in 2004, and it is still the safest way to move a monolith to microservices.

1. Why Big-Bang Rewrites Fail

Picture a city council's parking system: one Java EE application, fifteen years old, that handles resident permits, parking fines, appeals and payments. Every change takes weeks, and only two people understand the fines module.

The tempting plan is to rewrite it all as microservices and switch over on one weekend. That plan usually fails for three reasons:

  • The old system keeps changing. New laws and fee rules still have to be added to the monolith while the rewrite is under way, so the new system chases a moving target.
  • Nobody knows every rule. Fifteen years of fixes hide in the old code, such as a rounding rule or a special case for disabled drivers. The rewrite misses some of them, and they only show up after the switch.
  • There is no value until the end. For a year or more, the business pays for two systems and gets nothing new.

The strangler fig pattern avoids all three. Each step is small, goes live on its own and can be undone.

2. Key Terms

  • Legacy system (monolith): the old application being replaced. It stays in production during the whole migration.
  • Facade (routing layer): the single entry point that receives every request and decides whether the old system or a new service handles it. It is often an API gateway or a reverse proxy such as NGINX.
  • Seam: a place where a feature can be cut out cleanly, such as a group of URLs, a message queue or a scheduled job.
  • Shadow traffic: sending a copy of real requests to the new service, comparing its answers with the old system's, and throwing them away.
  • Anti-corruption layer: translation code that stops the old system's confusing data model from leaking into the new services.

3. How the Strangler Fig Pattern Works

Strangler fig migration in progressClients send every request to the facade. The facade routes /permits to the new permit-service, /fines to the new fine-service, and all other routes to the legacy monolith. Each new service has its own database, and the monolith keeps its legacy database. Change events from the legacy database are streamed to the permit database while both systems still need the data. Clientsweb, mobile Facaderouting table permit-service fine-service Legacy monolitheverything else permit DB fine DB legacy DB /permits /fines all other routes change events (CDC)
The facade sends migrated routes to new services and everything else to the monolith; data is synced from the legacy database while both sides still need it.

The migration repeats the same loop for each feature:

  1. Put the facade in front of the old system first, with every route going to the monolith. Nothing changes for users yet, but you now control the traffic.
  2. Pick a seam, ideally a feature with few dependencies that changes often, so the new service pays off quickly.
  3. Build the new service with its own database, and sync the data it needs from the old system.
  4. Verify it with shadow traffic, then send a small share of real users to it.
  5. Switch the route in the facade for everyone. If something goes wrong, switch it back.
  6. Delete the old code for that feature from the monolith. This step is easy to forget and essential.

When the monolith handles no routes and no jobs, you turn it off.

What happens internally: routing requests is the easy half; data is the hard half. While permits live in the new service, the monolith's fines module may still join against the old permits table. So for a while, the data must exist in both places. Teams usually keep the old database as the source and stream its changes to the new service with change data capture (CDC), for example Debezium reading the database's transaction log. Once the new service becomes the owner, the direction flips and the new service publishes changes back to the monolith until no old code reads the table.

4. Code Examples

These JDK-only Java 21 programs show the three moving parts: routing, verification and rollout.

4.1 Example 1: A Strangler Facade With a Routing Table

The facade sends each request by URL prefix. Migrating a feature, keeping part of it behind, and rolling back are all one-line changes.

StranglerFacadeDemo.java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class StranglerFacadeDemo {

    interface Backend { String handle(String path); }

    // The old system: one application that still knows how to do everything
    static final Backend LEGACY = path -> "legacy monolith";

    // New microservices, added one at a time
    static final Backend PERMIT_SERVICE = path -> "permit-service";
    static final Backend FINE_SERVICE = path -> "fine-service";

    // The facade: every request enters here; the routing table decides who answers
    static class StranglerFacade {
        private final TreeMap<String, Backend> routes = new TreeMap<>();

        void migrate(String pathPrefix, Backend newOwner) { routes.put(pathPrefix, newOwner); }
        void rollBack(String pathPrefix) { routes.remove(pathPrefix); }

        String handle(String path) {
            // longest matching prefix wins, so /fines/appeals can stay legacy while /fines moves
            for (Map.Entry<String, Backend> e : routes.descendingMap().entrySet()) {
                if (path.startsWith(e.getKey())) return e.getValue().handle(path);
            }
            return LEGACY.handle(path); // anything not migrated yet still goes to the monolith
        }
    }

    static void show(String phase, StranglerFacade facade, List<String> paths) {
        System.out.println(phase);
        for (String p : paths) System.out.printf("  %-22s -> %s%n", p, facade.handle(p));
    }

    public static void main(String[] args) {
        List<String> traffic = List.of("/permits/R-2291", "/fines/F-77", "/fines/appeals/A-5", "/payments/PAY-9");
        StranglerFacade facade = new StranglerFacade();

        show("Phase 0: facade in place, nothing migrated", facade, traffic);

        facade.migrate("/permits", PERMIT_SERVICE);
        show("Phase 1: permits moved", facade, traffic);

        facade.migrate("/fines", FINE_SERVICE);
        facade.migrate("/fines/appeals", LEGACY); // appeals are complex: keep them in the monolith for now
        show("Phase 2: fines moved, appeals kept", facade, traffic);

        facade.rollBack("/fines");                // a bug in fine-service: one line sends traffic back
        show("Rollback: fines back to legacy", facade, traffic);
    }
}
Output
Phase 0: facade in place, nothing migrated
  /permits/R-2291        -> legacy monolith
  /fines/F-77            -> legacy monolith
  /fines/appeals/A-5     -> legacy monolith
  /payments/PAY-9        -> legacy monolith
Phase 1: permits moved
  /permits/R-2291        -> permit-service
  /fines/F-77            -> legacy monolith
  /fines/appeals/A-5     -> legacy monolith
  /payments/PAY-9        -> legacy monolith
Phase 2: fines moved, appeals kept
  /permits/R-2291        -> permit-service
  /fines/F-77            -> fine-service
  /fines/appeals/A-5     -> legacy monolith
  /payments/PAY-9        -> legacy monolith
Rollback: fines back to legacy
  /permits/R-2291        -> permit-service
  /fines/F-77            -> legacy monolith
  /fines/appeals/A-5     -> legacy monolith
  /payments/PAY-9        -> legacy monolith

The longest-prefix rule lets you move /fines while keeping /fines/appeals in the monolith, so features can be split more finely than "whole module". In production the same table lives in an API gateway configuration, such as Spring Cloud Gateway routes, and changes without redeploying anything.

4.2 Example 2: Shadow Traffic Finds a Hidden Rule

Before switching the fine calculation, the facade sends each request to both systems, returns the legacy answer to the citizen, and logs any difference.

ShadowCompareDemo.java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;

public class ShadowCompareDemo {

    // Legacy rule, written years ago: 12.5% late fee, rounded half up
    static BigDecimal legacyLateFee(BigDecimal fine) {
        return fine.multiply(new BigDecimal("0.125")).setScale(2, RoundingMode.HALF_UP);
    }

    // New service: a developer re-implemented the rule and picked banker's rounding
    static BigDecimal newLateFee(BigDecimal fine) {
        return fine.multiply(new BigDecimal("0.125")).setScale(2, RoundingMode.HALF_EVEN);
    }

    public static void main(String[] args) {
        // Shadow mode: the facade calls both, returns the legacy answer, and only logs differences
        List<String> mismatches = new ArrayList<>();
        int compared = 0;
        for (int cents = 2000; cents < 6000; cents += 10) {   // fines from 20.00 to 59.90
            BigDecimal fine = BigDecimal.valueOf(cents, 2);
            BigDecimal answer = legacyLateFee(fine);           // what the citizen sees
            BigDecimal shadow = newLateFee(fine);              // computed, compared, thrown away
            compared++;
            if (answer.compareTo(shadow) != 0) {
                mismatches.add(fine + ": legacy " + answer + ", new " + shadow);
            }
        }
        System.out.println("Requests compared: " + compared);
        System.out.println("Mismatches: " + mismatches.size());
        mismatches.stream().limit(3).forEach(m -> System.out.println("  " + m));
        System.out.println("Safe to switch: " + mismatches.isEmpty());
    }
}
Output
Requests compared: 400
Mismatches: 50
  20.20: legacy 2.53, new 2.52
  21.00: legacy 2.63, new 2.62
  21.80: legacy 2.73, new 2.72
Safe to switch: false

One fine in eight produced a different late fee, off by one cent. The old code rounded half up; the new code used banker's rounding (HALF_EVEN), which rounds a final 5 to the nearest even digit. No specification mentioned it, and unit tests written from the specification would all pass. Shadow traffic found it with zero impact on citizens. Fix the new service, compare again, and switch only when the mismatch count is zero or every remaining difference is understood and accepted.

4.3 Example 3: Moving Users Over Gradually

Even after shadow testing, switch traffic step by step. Users are assigned to a fixed bucket so each person consistently sees one system.

GradualRolloutDemo.java
import java.util.List;
import java.util.stream.IntStream;

public class GradualRolloutDemo {

    // Sticky bucket 0-99 per citizen, so the same person always lands on the same side
    static int bucket(String citizenId) {
        return Math.floorMod(citizenId.hashCode(), 100);
    }

    static boolean useNewService(String citizenId, int percent) {
        return bucket(citizenId) < percent;
    }

    public static void main(String[] args) {
        List<String> citizens = IntStream.rangeClosed(1, 1_000)
                .mapToObj(i -> "citizen-" + i).toList();

        for (int percent : new int[] {0, 5, 25, 100}) {
            long onNew = citizens.stream().filter(c -> useNewService(c, percent)).count();
            System.out.printf("Rollout %3d%%: %4d of 1000 citizens on permit-service%n", percent, onNew);
        }

        // Stickiness: raising the percentage only adds people, nobody flips back and forth
        long flipped = citizens.stream()
                .filter(c -> useNewService(c, 5) && !useNewService(c, 25))
                .count();
        System.out.println("Citizens on the new service at 5% but not at 25%: " + flipped);

        // Same citizen, ten requests in a row, always the same answer
        String someone = "citizen-421";
        boolean first = useNewService(someone, 25);
        boolean allSame = IntStream.range(0, 10).allMatch(i -> useNewService(someone, 25) == first);
        System.out.println(someone + " always routed the same way: " + allSame);
    }
}
Output
Rollout   0%:    0 of 1000 citizens on permit-service
Rollout   5%:   61 of 1000 citizens on permit-service
Rollout  25%:  268 of 1000 citizens on permit-service
Rollout 100%: 1000 of 1000 citizens on permit-service
Citizens on the new service at 5% but not at 25%: 0
citizen-421 always routed the same way: true

Hash-based buckets give roughly the requested share (61 instead of 50 at 5% here, because 1,000 users is a small sample), and they are sticky: raising the percentage only adds users. Stickiness matters because a citizen who renews a permit on the new service and then views it on the old one may see stale data. In a real system, the bucket key is usually the user id or account id, and the percentage lives in a feature flag tool so it can change without a deployment.

5. Strangler Fig vs Other Migration Approaches

ApproachHow it worksRiskValue deliveredRollback
Big-bang rewriteBuild a full replacement, switch on one dayVery highOnly at the endHard or impossible
Strangler figReplace one feature at a time behind a facadeLow per stepAfter every stepChange one route back
Branch by abstractionReplace a component inside the codebase behind an interfaceLowGradualSwitch the implementation back
Lift and shiftMove the monolith unchanged to new infrastructure, such as the cloudLowInfrastructure onlyMove it back

Branch by abstraction works inside one codebase and suits internal components, such as a persistence layer. The strangler fig works at the boundary between systems. Many migrations use both.

6. Common Mistakes and Pitfalls

1. Never deleting the old code. If the monolith still contains the permit logic after the route has moved, someone will fix a bug in the wrong place, and the monolith never shrinks. Remove migrated code right after each switch.

2. Starting with the hardest part. The core module that everything depends on is the worst first choice. Start with something small and fairly independent to learn how routing, data sync and deployment work.

3. A shared database "for now". Letting the new service read and write the monolith's tables is quick, but it ties both systems to the same schema, which is exactly the coupling you are trying to remove. Give the new service its own data and sync it.

4. Copying the legacy model. If the new service's API mirrors the monolith's table names and odd status codes, the mess simply moves. Use an anti-corruption layer to translate at the boundary.

5. Forgetting non-HTTP entry points. Batch jobs, scheduled reports, file imports and messages from other systems also call the monolith. A facade on the HTTP path doesn't cover them, so each needs its own redirection plan.

6. A migration with no end. Some projects move the easy 70% and then stop, leaving two systems to run forever. Track which routes remain and set a date to switch off the monolith.

7. Best Practices

  • Add the facade before migrating anything, so every later step is just a routing change.
  • Choose seams along business boundaries, such as bounded contexts, not technical layers.
  • Verify with shadow traffic before real users see the new service.
  • Roll out gradually with sticky routing, and keep the switch back to legacy ready.
  • Plan data ownership for each feature: who owns it, how it is synced, and when the direction changes.
  • Measure progress by the share of traffic and code still handled by the monolith.

8. Summary

  • The strangler fig pattern replaces a legacy system one feature at a time behind a routing facade.
  • Every step is small, delivers value and can be rolled back by changing a route.
  • Data synchronization, not routing, is usually the hardest part.
  • Shadow traffic finds hidden legacy rules before users are affected.
  • Sticky, gradual rollouts keep each user's experience consistent.
  • Delete migrated code and keep going until the monolith can be switched off.

Start by putting a facade in front of your monolith and moving one small, well-understood feature. Done patiently, the strangler fig pattern turns a frightening rewrite into a series of routine releases.