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 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.
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.
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
- Saga Pattern Microservices Implementation
- API Gateway vs Service Mesh Comparison
- Vertical Slice Architecture Guide
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.