The Strangler Fig: Modernizing Legacy Without the Big-Bang Rewrite
Why incremental replacement beats the rewrite, and how to route traffic through a facade while you carve a monolith into modular services.
Big-bang rewrites fail for a reason we keep re-learning: they ask the business to stop while engineering rebuilds a system nobody fully understands. The Strangler Fig pattern — named after the vine that grows around a tree until it can stand on its own — offers the opposite bet. You wrap the legacy system, redirect one capability at a time, and let the old system wither only once nothing depends on it.
The shape of the migration
A facade sits in front of the legacy system and decides, per request, whether to serve from the old path or a newly extracted module. Early on almost everything routes to legacy; over time the balance inverts.
The facade is the whole game. It gives you a seam — a place to change behavior without changing either side — and it makes every migration step independently reversible. If the extracted Orders service misbehaves, you flip a flag and route back to the monolith.
Deciding what to strangle first
Pick the capability with the highest change frequency-to-risk ratio: something you touch often but can extract without unwinding the entire data model. A crude scoring pass in Python:
from dataclasses import dataclass
@dataclass
class Capability:
name: str
monthly_changes: int # how often the team edits it
coupling_score: float # 0 = isolated, 1 = deeply entangled
@property
def priority(self) -> float:
# Reward frequent change, penalize entanglement.
return self.monthly_changes / (1 + 5 * self.coupling_score)
candidates = [
Capability("orders", monthly_changes=18, coupling_score=0.30),
Capability("billing", monthly_changes=12, coupling_score=0.55),
Capability("reporting", monthly_changes=3, coupling_score=0.10),
]
for c in sorted(candidates, key=lambda c: c.priority, reverse=True):
print(f"{c.name:12} priority={c.priority:.1f}")
Implementing the seam
Keep the facade dumb and declarative. Here it is as a small piece of routing middleware in Go, driven by a feature flag so migration is a config change, not a deploy:
// Route sends extracted capabilities to the new service and everything
// else to the legacy upstream. Flipping the flag is instant and reversible.
func (f *Facade) Route(w http.ResponseWriter, r *http.Request) {
upstream := f.legacy
if f.flags.Enabled("orders.extracted") && strings.HasPrefix(r.URL.Path, "/orders") {
upstream = f.ordersService
}
upstream.ServeHTTP(w, r)
}
And the flag config that drives it — plain, reviewable, and the actual unit of migration progress:
flags:
orders.extracted: true # migrated 2026-07 ✅
billing.extracted: false # in progress
reporting.extracted: false # not started
The rule that keeps you honest
Every step must leave the system shippable. If a migration step can’t be deployed to production on its own, it’s too big — cut it smaller.
That constraint is what separates the Strangler Fig from a rewrite wearing a disguise. The monolith keeps earning its keep the entire time, and the risk of each step stays bounded to a single capability you can flip back in seconds.