Monoliths don't die overnight — How to design traffic boundaries and data synchronization with the Strangler Fig pattern
When someone asks "Can't we just rewrite everything from scratch?" in a legacy system migration meeting, I silently sigh. The industry is full of stories about big-bang rewrites gone wrong, and most of those failures begin the moment someone says "let's switch everything over once it's all done." You can't stop a running system, the team has to fix bugs in both the new code and the legacy simultaneously, and at some point the gap between the two versions grows too wide to bridge.
The Strangler Fig pattern confronts this problem honestly. Like the strangler fig of the tropical rainforest, the new service gradually wraps around the monolith, absorbing one functional slice at a time. The essence of this pattern, named by Martin Fowler, is simple — don't try to replace the entire system at once; identify functional boundaries and migrate them one by one.
In practice, though, applying this pattern runs into two sticking points. First, where to cut the traffic — how do you define the boundary, and how do you transition it incrementally? Second, when to separate the data — far harder than extracting code, and if done wrong, you end up with a distributed monolith. This post focuses on those two issues.
How the Pattern Actually Works — A Three-Phase Loop
The theory is simple. Let's start with a diagram of the cycle as it actually runs.
The key point is that this loop repeats. Once a slice is fully migrated, immediately delete that code from the monolith and move on to the next slice. Delaying deletion creates confusion later from code that was "kept just in case."
Slice Selection Criteria
Use DDD's Bounded Context and Event Storming to choose migration candidates. A good first slice satisfies the following conditions.
| Criterion | Reason |
|---|---|
| Clear input/output boundary | Minimizes coupling points with the monolith, preventing migration scope from expanding |
| Low coupling | Fewer synchronous call dependencies on other domains is better |
| High business value | Creates early wins that build stakeholder trust |
| Independent data | Domains with no shared tables are ideal for the first stage |
This is why domains like notifications or user-management are often chosen as first migration targets. Core domains like order-processing that are entangled with multiple other domains are deferred to later stages.
Traffic Transition Boundary — The Façade Determines Everything
The Role of the Façade
Once an API gateway sits in front of the monolith, every request passes through it. Fully migrated features are forwarded to the new service; features still being migrated pass through to the monolith.
How you configure this Façade determines the speed and safety of migration.
Configuring Routing Rules with Kong
Below is a conceptual example based on Kong's Declarative Config (deck) format.
# kong.yaml (conceptual example - deck format)
services:
- name: user-service
url: http://user-service:8080
routes:
- name: user-routes
paths:
- /api/users
- /api/auth
# user-management migration complete → forward to new service
- name: monolith
url: http://monolith:8080
routes:
- name: monolith-routes
paths:
- /api/orders
- /api/products
# domain not yet migrated → pass through to monolith
plugins:
- name: request-transformer
service: user-service
config:
add:
headers:
- "X-Migrated-Service:true"Phased Transition with Canary Deployment
Rather than switching traffic all at once, shift it in multiple stages. Depending on your organization's risk tolerance, you might go 1% → 10% → 50% → 100%, or start even more conservatively at 0.1%. There's no single right answer — it's determined by how quickly your team can detect and revert failures. With AWS ALB you can control the ratio using weighted target groups; with Istio, use VirtualService.
# Istio VirtualService example (conceptual)
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: order-processing
spec:
hosts:
- order-processing.internal
http:
- route:
- destination:
host: order-service # new microservice
weight: 25
- destination:
host: monolith
weight: 75The most important thing during the canary phase is defining rollback conditions in advance. Without SLO-based conditions like "automatically revert if error rate exceeds 1%," your response time when problems arise will be too slow.
Adding Fine-Grained Control with Feature Flags
Integrating feature flag tools like LaunchDarkly or Unleash into the gateway lets you switch traffic at the level of specific user segments — for example, "only beta users go to the new order service." When a problem occurs, a single flag reverts it instantly. No code deployment needed.
Honestly, I initially thought feature flags were overkill, but after experiencing how hard it is to isolate bugs that only reproduce on specific accounts using canary deployment alone, I changed my mind.
Data Synchronization — The Really Hard Part
Extracting code is a finite task, but data is different. If you've separated the services but they still share a DB, you have something that's logically a microservice but in practice a distributed monolith.
Database separation proceeds in three stages.
Stage 1: Shared DB — Starting Fast
The new service and the monolith use the same DB. Code is separated, but data is still unified. The reason you need this state initially is to validate code extraction first, without database separation.
This stage is a transitional period. Staying here too long keeps tight coupling intact. Move to Stage 2 once the new service's own DB schema is designed and you're ready to move the write path.
Stage 2: Dual Write — The Transitional Period for Ownership Transfer
Dual writes often start as a stopgap and solidify into core infrastructure that no one can remove. I've seen a synchronization layer that started as "we'll remove it later" become an untouchable black box two years down the line. That's why I put this note at the top before writing any Stage 2 code.
In this stage, the new service writes to its own DB and also writes to the monolith DB within the same request-handling flow. The example below is the simplest form — two DB calls inlined without a separate synchronization component. In practice, it's safer to consolidate both writes into a single transactional outbox table to ensure atomicity.
# Dual write - inline approach (conceptual example, not for production)
def create_order(order_data):
# Save to new service DB
new_db.orders.insert(order_data)
# Warning: if the process crashes right before this line, the data will exist
# only in the new DB and synchronization to the monolith DB will never be
# attempted. try/except catches exceptions but not process termination. If
# atomicity is required, use the outbox pattern or a transactional queue
# to wrap both writes in a single local transaction.
try:
legacy_db.orders.insert(transform_to_legacy(order_data))
except Exception as e:
sync_queue.enqueue({"action": "sync_order", "data": order_data})
logger.error(f"Legacy sync failed: {e}")The criteria for moving from Stage 2 to Stage 3 are clear: the point at which the monolith's write path has effectively disappeared, making the new service the sole writer. Specifically: (1) all write paths to the domain's tables have been removed from the monolith code, (2) canary is at 100% toward the new service, and (3) no direct writes to the monolith DB have been observed over a set period. Switching to CDC before reaching this state means events will get tangled while both sides are still writing, breaking consistency.
Stage 3: Complete Separation with CDC
Debezium detects changes in PostgreSQL or MySQL transaction logs (WAL/binlog) and publishes change events to Kafka. It can detect data changes without touching a single line of monolith code.
// Example event Debezium publishes to Kafka (structural example, field values are arbitrary)
{
"before": null,
"after": {
"id": 12345,
"user_id": 789,
"status": "created",
"total": 59000,
"created_at": 1720000000000
},
"source": {
"version": "2.3.0.Final",
"connector": "postgresql",
"db": "monolith_db",
"table": "orders"
},
"op": "c"
}source.version contains the Debezium release string actually in use (the 2.3.0.Final in the example above is just a format illustration; replace it with the exact version adopted by your project). The new service subscribes to these Kafka events and updates its own DB.
The Debezium + Kafka combination is one of the most widely adopted for CDC-based data synchronization, and Gunnar Morling's talk on Debezium + Kafka + MongoDB in the references is an excellent real-world example of this approach. The core advantage of this approach is that it explicitly accepts eventual consistency in exchange for not touching monolith code at all.
A Real Scenario — Decomposing an E-Commerce Order System
Let's take a scenario commonly seen in large-scale real-world e-commerce platforms. The API gateway's routing table explicitly tracks the migration status of each domain.
# Routing table snapshot (example - as of 2026-09-10)
routing_rules:
- domain: user-management
status: migrated # migration complete
target: user-service:8080
canary_weight: 100
- domain: notifications
status: migrated # migration complete
target: notification-service:8080
canary_weight: 100
- domain: order-processing
status: in_progress # currently in canary phase
targets:
- service: order-service:8080
weight: 25
- service: monolith:8080
weight: 75
- domain: inventory
status: pending # migration scheduled
target: monolith:8080
canary_weight: 0While order-processing is in a 25% canary phase, the already-completed user-management and notifications have all traffic flowing exclusively to the new services. Their code has already been deleted from the monolith.
Data Ownership Must Belong to One Side
The situation most critical to avoid in this process is the monolith and the new service jointly writing to the same table. Once it becomes unclear which system is the "source of truth," consistency guarantees become impossible.
The principle is clear: there must always be exactly one owner of the data. Even during the canary phase, if a particular request went to the new service, that data is written to the new service's DB. Requests that went to the monolith write to the monolith's DB. CDC handles synchronization between the two DBs.
Trade-offs — You Should Choose With Full Awareness
| Item | Advantage | Caveat |
|---|---|---|
| Zero-downtime transition | Migrate while running, no big-bang rewrite | The Façade itself can become a single point of failure |
| Incremental risk distribution | Failure of one slice doesn't affect the whole | Increased cost of running two systems simultaneously |
| Immediate rollback | Instant recovery via feature flag or gateway config | Slow decision-making if rollback conditions aren't predefined |
| CDC synchronization | Detect data changes without modifying monolith code | Eventual consistency — unsuitable for domains requiring real-time consistency |
| Organizational learning | Gain operational know-how from early migrations | Hard to maintain boundaries if team boundaries don't align with domain boundaries |
Common Mistakes Seen in Practice
1. Failing to Freeze Monolith Code Adding new features to the monolith during migration continuously expands the migration scope. New features must be added only to the new service. If this rule isn't followed, the migration never ends.
2. Boundary Definition Errors Drawing service boundaries incorrectly leaves synchronous call dependencies on the monolith even after migration — the so-called distributed monolith. Nothing improves except adding a network hop. The two-phase path — monolith → modular monolith → microservices — which uses Event Storming to clarify domain boundaries at the code level before physical service separation, is how you reduce this risk.
3. Attempting the First Extraction Without Observability Deploying the first service before having distributed tracing (Jaeger, Zipkin), metrics (Prometheus + Grafana), and log aggregation in place means you won't know where or why something went wrong when problems occur. Getting instrumentation in place with OpenTelemetry first is the right order of operations.
4. Lock-in to Managed Migration Services Managed tools like AWS Refactor Spaces let you get a Façade configured quickly, but be aware that specific vendor products' availability policies can change (similar cases have occurred recently), so always check the vendor's latest official announcements before adopting a tool. Alternatives include directly combining Application Load Balancer weighted routing with API Gateway, which has the advantage of lower vendor dependency.
Closing — Two Axes of Failure: Boundaries and Timing
The Strangler Fig pattern is appealing precisely because you can reverse course if something goes wrong. If you migrate a slice and it turns out badly, change the gateway config and route back to the monolith. A failure in a big-bang rewrite can wipe out months of work; a failure here rolls back just that one slice.
This post covered two axes: traffic boundaries and data timing. I'll close by cross-referencing the points where teams most often stumble on those two axes.
Where teams most often stumble on traffic boundaries When gateway routing starts being managed in people's heads rather than in code, a moment arrives when no one is certain what phase any given domain is in. Routing tables must be managed declaratively, committed to a repository. And if canary rollback conditions aren't nailed down as SLOs in advance, when problems occur the repeated judgment of "let's watch a bit longer" causes you to miss the rollback window.
Where teams most often stumble on data timing The two extreme mistakes are staying in Stage 2 dual write too long, and moving to Stage 3 CDC too early. The former results in a synchronization layer that hardens and can never be removed; the latter results in events getting tangled while both sides are still writing, breaking consistency. The timing of the transition is judged by a single criterion: "Has the monolith's write path effectively disappeared?"
Where the two axes meet The most dangerous situation is when traffic has been moved to the new service, but data ownership still remains with the monolith. If requests that flow to the new service still ultimately need to write to the monolith's DB, you've simply added one network hop. The traffic transition plan and the data ownership transfer plan must be drawn on the same calendar.
References
- Strangler Fig Pattern — Azure Architecture Center
- Strangler Fig Pattern — AWS Prescriptive Guidance
- Strangler Fig Pattern for Modular Monolith Migration — Milan Jovanović
- Replacing Legacy Systems One Step at a Time with Data Streaming — Kai Waehner (2025)
- Strangler Fig Pattern with Event Streaming — Conduktor
- The Hidden Impediment of the Strangler Fig Pattern — Vahid Bakhtiary
- Embracing the Strangler Fig Pattern for Legacy Modernization — Thoughtworks
- Monolith to Microservices Migration: Strangler Fig, DDD, and Why Most Teams Get It Wrong — CloudRPS
- Designing the Façade: How API Gateways Make the Strangler Fig Pattern Work — ItsAVirus
- Dissecting Our Legacy: Strangler Fig with Debezium, Kafka & MongoDB — Gunnar Morling