Strangler Fig Pattern: Replace a Monolith One Piece at a Time
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
The migration repeats the same loop for each feature:
- 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.
- Pick a seam, ideally a feature with few dependencies that changes often, so the new service pays off quickly.
- Build the new service with its own database, and sync the data it needs from the old system.
- Verify it with shadow traffic, then send a small share of real users to it.
- Switch the route in the facade for everyone. If something goes wrong, switch it back.
- 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.
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.
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);
}
}
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.
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());
}
}
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.
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);
}
}
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
| Approach | How it works | Risk | Value delivered | Rollback |
|---|---|---|---|---|
| Big-bang rewrite | Build a full replacement, switch on one day | Very high | Only at the end | Hard or impossible |
| Strangler fig | Replace one feature at a time behind a facade | Low per step | After every step | Change one route back |
| Branch by abstraction | Replace a component inside the codebase behind an interface | Low | Gradual | Switch the implementation back |
| Lift and shift | Move the monolith unchanged to new infrastructure, such as the cloud | Low | Infrastructure only | Move 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.
Strangler Fig Pattern: Interview Questions
Common interview questions on Strangler Fig Pattern, from basic to scenario-based, with short answers.
Basic questions
Q. Where does the name "strangler fig" come from?
Answer: Strangler figs are tropical plants that start growing on a host tree, send roots down to the ground and gradually surround the host until they stand on their own. Martin Fowler used the image in 2004 for replacing a legacy system by growing a new one around it. Some people now say "strangler pattern" for short.
Q. How do you choose the first feature to migrate?
Answer: Look for a feature with clear boundaries, few dependencies on the rest of the monolith, and real business value from changing it, such as one that changes often or needs to scale. It should be important enough to prove the approach but not so central that a mistake hurts everything. Avoid the core module that every other part calls.
Q. What can act as the facade?
Answer: Anything that can route traffic: an API gateway such as Spring Cloud Gateway or Kong, a reverse proxy such as NGINX or Envoy, a load balancer with path rules, or, for messages, a router that forwards events to the right consumer.
spring.cloud.gateway.server.webflux.routes:
- id: permits
uri: http://permit-service
predicates: [ Path=/permits/** ]
Intermediate questions
Q. What is an anti-corruption layer, and why use one during a strangler migration?
Answer: It is a translation layer between the new service and the legacy system. It converts the old system's data and terms, such as cryptic status codes or combined fields, into the new service's clean model, and back again. Without it, the new service's design gets shaped by the legacy model it was meant to replace.
Q. How does the strangler fig pattern relate to branch by abstraction?
Answer: Both replace something gradually while the system keeps running. The strangler fig redirects traffic at the boundary of the system, while branch by abstraction introduces an interface inside the code and swaps the implementation behind it. They are often combined: branch by abstraction separates a module inside the monolith, then the strangler fig moves it out.
Q. How do you handle a feature that needs data from both the old and the new system?
Answer: Pick one owner for each piece of data and let the other side read a synced copy, kept up to date with change data capture or events. If a request needs both, the new service can call the monolith through the anti-corruption layer. Avoid both sides writing the same record, because conflicting writes are very hard to reconcile.
Q. Can you use the strangler fig pattern for the user interface too?
Answer: Yes. A common approach is to serve new pages from a new frontend and keep old pages on the legacy app, with the routing layer deciding by URL. Micro-frontends or embedding new components into old pages let teams move the UI screen by screen.
Q. What are the main costs of the strangler fig pattern?
Answer: For the duration of the migration you run and support two systems, a facade and data synchronization, which costs infrastructure and attention. The migration can also take years for a large system. These costs are usually lower than the risk of a failed big-bang rewrite, but they are real and should be planned for.
Q. How do you know when the migration is finished?
Answer: When the facade sends no traffic to the monolith, no batch jobs or integrations call it, and its database is no longer read or written by anything. Watch traffic and database access logs for a while to confirm, then switch it off and archive the data you're required to keep.
Tricky and scenario questions
Q. Scenario: After migrating payments, some users see their payment in the new service but not on the old account page. What went wrong?
Answer: The account page still reads from the legacy database, and payments written by the new service aren't synced back, or are synced with a delay. Either stream changes from the new service to the legacy database until the account page is migrated too, or move the account page next. Sticky routing alone can't fix a read model that lives on the other side.
Q. Scenario: Shadow traffic shows 0.3% mismatches you can't explain. Do you switch?
Answer: Not yet. Group the mismatches by type and investigate the largest group first, since each one may be a hidden rule like the rounding example. Some differences may turn out to be legacy bugs the business chooses to fix; document that decision explicitly, then switch gradually with close monitoring.
Q. Scenario: The monolith has a nightly batch job that updates every permit. How do you migrate permits?
Answer: The facade doesn't see batch jobs, so plan the job separately. Either move the job into the permit-service at the same time as the route, or make the job call the new service's API instead of writing to the old table. Running the old job unchanged after the switch would overwrite or miss data in the new service.
Q. Scenario: Halfway through the migration, the team wants to "temporarily" let the new fine-service read the monolith's database. What do you say?
Answer: It's tempting because it saves building a sync, but it couples the new service to the old schema, so the monolith's tables can't change without breaking it. If it's truly temporary, put it behind an anti-corruption layer, read only, and record a date for removing it. Otherwise, set up CDC so the new service has its own copy.