How to Build a Strangler Fig Facade in Python to Safely Cut Over From Legacy Code
Build a Strangler Fig facade in Python that routes traffic between legacy and new code with sticky rollout, shadow comparison, and an instant kill switch, catching two real migration bugs along the...
Most legacy migrations do not fail because the new code is wrong. They fail because the team cannot tell whether it is wrong until every customer is already using it. The usual advice, “just rewrite it,” skips the actual hard part: how do you replace a system that a business depends on without a moment where you flip a switch and hope?
Table Of Content
- What Is the Strangler Fig Pattern?
- Key Terms
- Prerequisites
- Step 1: Set Up the Project
- Step 2: Write the Legacy Implementation
- Step 3: Write the Naive Rewrite
- Step 4: Build the Strangler Facade
- How the Routing Decision Works
- Why Both Implementations Run on Every Call
- Step 5: Run Shadow Mode and Catch the First Bug
- Step 6: Resolve the Divergence, Not Just the Code
- Step 7: Start the Rollout and Find a Second Bug
- Step 8: Roll Back With the Kill Switch
- Step 9: Fix the Real Bug and Complete the Rollout
- Step 10: Wire the Facade Into a Real Endpoint
- Step 11: Remove the Legacy Path
- Common Mistakes and Gotchas
- A Silent Wrong Answer Is Not the Same Risk as an Exception
- The Facade Itself Is a New Single Point of Failure
- Dual Writes Need a Different Pattern Than This One
- Shadow Mode’s Sample Is Only as Good as Its Traffic
- Don’t Remove the Comparison Logging the Moment You Start Routing Live Traffic
- Step 12: Verify the Facade’s Guarantees With pytest
- How to Confirm It All Works End to End
- Next Steps
In this tutorial you will build a Strangler Fig facade: a small routing layer that sits in front of a legacy implementation and a rewritten one, sends real traffic to whichever side you choose on a per-customer basis, and keeps comparing both sides even after it starts routing live traffic. You will use it to migrate a loyalty-tier calculation from a tangled legacy function to a clean rewrite, and along the way you will personally trigger and fix two real migration bugs: one caught safely before a single customer sees it, and one that slips past a normal try/except and can only be caught by comparing outputs and having a fast way to back out.
What Is the Strangler Fig Pattern?
The name comes from an actual tree. Martin Fowler, the software architect who coined the term, described seeing strangler figs on a trip to the rain forests of Queensland, Australia in 2001 in his Strangler Fig bliki post: vines that germinate in a fork of a host tree, grow downward and outward, and gradually envelop it. “It can then become self-sustaining, and its original host tree may die leaving the fig as an echo of its shape.” Fowler thought this was “a striking analogy to the way I saw colleagues doing modernization of legacy software systems,” and the name stuck (he originally called it the “Strangler Application” pattern, then renamed it later to make the botanical metaphor clearer, since “strangler” alone made people think of violence rather than gradual replacement).
The mechanism is described precisely by Microsoft’s own Azure Architecture Center Strangler Fig pattern page: a facade sits between the client and both systems, and it “routes some requests to the legacy system and other requests to the new system.” At first almost everything goes to legacy. As you build and trust more of the new system, the facade routes a growing share of traffic to it. Once the legacy system has no traffic left, you decommission it and eventually remove the facade itself, leaving the client talking directly to the new system.
That is the whole pattern in one sentence: a facade that can shift traffic between two implementations of the same capability, gradually, safely, and reversibly. Everything below is one concrete way to build that facade.
Key Terms
- Migration slice: one bounded piece of behavior you migrate independently, such as “compute this customer’s loyalty tier,” not “rewrite the whole application.”
- Shadow mode: running the new implementation on real traffic, comparing its answer to the old one, but never letting its answer reach a caller. Zero customer-facing risk, full comparison data.
- Sticky routing: the same customer always lands on the same side of the facade, call after call, instead of a random coin flip each time.
- Kill switch: a single override that forces every request back to the legacy implementation, independent of whatever rollout percentage is currently configured.
Prerequisites
- Python 3.10 or newer (this tutorial uses the
X | Noneunion type syntax). - Comfortable with Python functions, classes, and basic HTTP APIs.
- No prior FastAPI experience required; the facade itself is plain Python, and FastAPI is only used in one step to show the production shape.
- Assumed background: you understand what a legacy code migration is and why “just rewrite it” is risky. If you want the testing techniques this tutorial builds on, two companion pieces cover them in depth: characterization tests for locking in existing behavior, and differential testing for comparing two implementations directly. This tutorial does not repeat either technique; it teaches the traffic-routing mechanism that decides who is exposed to the new implementation, and when.
Step 1: Set Up the Project
Create a project directory and an isolated virtual environment:
mkdir strangler-fig-tutorial
cd strangler-fig-tutorial
python -m venv venv
venv\Scripts\activate (Windows)
source venv/bin/activate (macOS/Linux)
pip install fastapi httpx2 pytest
Three packages, and only three. fastapi is the web framework you will wire the facade into. pytest runs the test suite at the end. httpx2 is the newer client library that FastAPI’s own TestClient now expects; if you install the older httpx package instead, everything still works, but you will see a deprecation warning every time you import fastapi.testclient. That is a real, current detail: FastAPI 0.141.1 pins Starlette 1.6.0, and Starlette’s own testclient module now imports httpx2 first and falls back to httpx with a warning if httpx2 is not installed. Installing httpx2 up front avoids the warning entirely.
Verify the install:
python -c "import fastapi, pytest; print('fastapi', fastapi.__version__); print('pytest', pytest.__version__)"
fastapi 0.141.1
pytest 9.1.1
(Your exact version numbers may differ; what matters is that both import cleanly.)
Step 2: Write the Legacy Implementation
Every migration starts with something that already works and that a real business depends on, quirks included. Create legacy_loyalty.py:
"""The legacy monolith's loyalty-tier logic.
Nobody remembers exactly why the Gold threshold is a strict ">" instead
of ">=". It has been this way since 2019 and changing it would silently
re-tier every customer sitting at exactly $2,000 of spend.
"""
def compute_tier(total_spend: float, account_age_days: int) -> str:
if account_age_days >= 365 and total_spend >= 10000:
return "Platinum"
if total_spend > 2000:
return "Gold"
if total_spend >= 500:
return "Silver"
return "Bronze"
This is the kind of function that accumulates in a production codebase over years: four tiers, decided by spend and account age, with one detail easy to miss on a fast read. The Gold threshold is a strict >, not >=. Nobody currently on the team remembers why; it has simply been true since the tier logic was written, and every customer who has ever sat at exactly $2,000 of spend has stayed Silver because of it. That is exactly the kind of undocumented, load-bearing detail a rewrite tends to lose without anyone intending to change it.
Step 3: Write the Naive Rewrite
Now write the replacement the way a competent developer actually would: from the product specification, not from reading the legacy source line by line. Create new_loyalty.py:
def compute_tier(total_spend: float, account_age_days: int) -> str:
if account_age_days >= 365 and total_spend >= 10000:
return "Platinum"
if total_spend >= 2000:
return "Gold"
if total_spend >= 500:
return "Silver"
return "Bronze"
Read on its own, this function looks correct, arguably more correct than the legacy version. Using >= for a “$2,000 or more” threshold reads as the natural, intention-revealing way to write it. That is what makes this bug dangerous: it will not show up in code review as a mistake. It will show up as a values judgment nobody signed off on.
Step 4: Build the Strangler Facade
The facade has three jobs on every single call: decide which implementation should answer this specific customer, call the other implementation anyway so you can compare, and log any disagreement without ever letting a broken comparison break the response. Create strangler.py:
"""A Strangler Fig facade: routes between a legacy and a new implementation.
The bucketing function is the same sticky, deterministic hash-bucket trick
built in the feature-flags tutorial (sxz.io/feature-flags-percentage-rollout-python-tutorial/).
A strangler cutover needs the exact same guarantee a feature flag rollout
needs: the same customer must land on the same side of the line every time,
or you get inconsistent behavior that looks like a bug even when the
routing is working exactly as designed.
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from typing import Any, Callable
logger = logging.getLogger("strangler")
def _bucket(key: str, salt: str) -> int:
"""Map key -> a stable integer in [0, 99], stable across processes."""
digest = hashlib.sha256(f"{salt}:{key}".encode("utf-8")).hexdigest()
return int(digest[:8], 16) % 100
@dataclass
class DivergenceRecord:
key: str
args: tuple
kwargs: dict
legacy_result: Any = None
legacy_error: str | None = None
new_result: Any = None
new_error: str | None = None
@property
def diverged(self) -> bool:
if self.legacy_error or self.new_error:
return True
return self.legacy_result != self.new_result
@dataclass
class Strangler:
"""Routes calls between legacy_fn and new_fn for one migration slice."""
legacy_fn: Callable[..., Any]
new_fn: Callable[..., Any]
salt: str
rollout_percent: int = 0
kill_switch: bool = False
divergences: list[DivergenceRecord] = field(default_factory=list)
def handle(self, key: str, *args: Any, **kwargs: Any) -> Any:
"""Call both implementations, log any divergence, return one answer."""
record = DivergenceRecord(key=key, args=args, kwargs=kwargs)
try:
record.legacy_result = self.legacy_fn(*args, **kwargs)
except Exception as exc: # noqa: BLE001 - legacy failures are data, not crashes
record.legacy_error = repr(exc)
try:
record.new_result = self.new_fn(*args, **kwargs)
except Exception as exc: # noqa: BLE001 - a broken new_fn must never break the facade
record.new_error = repr(exc)
if record.diverged:
self.divergences.append(record)
logger.warning(
"DIVERGENCE key=%s args=%s kwargs=%s legacy=%r/%s new=%r/%s",
key,
args,
kwargs,
record.legacy_result,
record.legacy_error,
record.new_result,
record.new_error,
)
route_to_new = (not self.kill_switch) and _bucket(key, self.salt) < self.rollout_percent
if route_to_new:
if record.new_error is not None:
# The bucket says "new", but new blew up. Fail safe: fall back
# to legacy rather than propagate an error a customer never
# would have seen a minute ago.
logger.error("new_fn failed for key=%s, falling back to legacy", key)
if record.legacy_error is not None:
raise RuntimeError(record.legacy_error)
return record.legacy_result
return record.new_result
if record.legacy_error is not None:
raise RuntimeError(record.legacy_error)
return record.legacy_result
How the Routing Decision Works
_bucket() hashes a salted version of the customer’s key with SHA-256 and takes the first four hex digits (16 bits) as an integer, then reduces it modulo 100. That gives every customer a stable integer from 0 to 99. “Stable” is the entire point: the hash is a pure function of the key and the salt, so the same customer lands in the same bucket every time, in every process, forever, without storing anything. If you have already read the site’s tutorial on building a feature flag system with sticky percentage rollouts, this will look familiar on purpose: a strangler cutover needs exactly the same guarantee a feature-flag rollout needs, and it is the same technique solving it.
Routing to the new implementation happens when _bucket(key, salt) < rollout_percent. At rollout_percent=0, that condition is never true (bucket values start at 0, so an empty range), which means every single call still goes through new_fn for comparison, but the caller-visible answer always comes from legacy_fn. That state, comparing everything while changing nothing for the caller, is shadow mode, and it is where every migration should start.
Why Both Implementations Run on Every Call
Notice that handle() always calls legacy_fn and new_fn, regardless of the routing decision. This is deliberate and it is the part most homegrown migration scripts skip. If you only called new_fn once you started routing traffic to it, you would lose your comparison data exactly when you need it most: while a growing share of real customers are exposed to it. Running both, every time, means the divergence log keeps working identically whether you are at 0 percent or 90 percent rollout.
The cost is real: you are doing twice the work on every request for the lifetime of the migration. For a pure function like a loyalty-tier lookup that cost is negligible. For a capability with expensive side effects, this same technique needs real adaptation, covered in the gotchas section below.
Step 5: Run Shadow Mode and Catch the First Bug
Wire the two implementations into the facade at rollout_percent=0 and run it against a handful of customers, including one sitting exactly on the $2,000 boundary:
import logging
import sys
import legacy_loyalty
import new_loyalty
from strangler import Strangler
logging.basicConfig(level=logging.WARNING, format="LOG: %(message)s", stream=sys.stdout)
print("--- confirm the two implementations actually disagree at spend=2000 ---")
print("legacy_loyalty.compute_tier(2000, 40) =", legacy_loyalty.compute_tier(2000, 40))
print("new_loyalty.compute_tier(2000, 40) =", new_loyalty.compute_tier(2000, 40))
print()
print("--- shadow mode (rollout_percent=0) over a batch of synthetic customers ---")
customers = [
("cust-001", 0, 10),
("cust-002", 500, 200),
("cust-003", 1999, 500),
("cust-004", 2000, 500), # the boundary case
("cust-005", 2001, 500),
("cust-006", 15000, 900),
("cust-007", 15000, 100), # Platinum requires account_age >= 365 too
("cust-008", 750, 30),
("cust-009", 4000, 1000),
("cust-010", 2000, 1000), # a second boundary case, different age
]
strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=0,
)
results = []
for customer_id, spend, age in customers:
tier = strangler.handle(customer_id, spend, age)
results.append((customer_id, spend, age, tier))
print(f"{customer_id}: spend={spend:<6} age={age:<5} -> caller sees {tier!r}")
print()
print(f"Total divergences logged (not shown to any caller): {len(strangler.divergences)}")
for d in strangler.divergences:
print(f" key={d.key} args={d.args} legacy={d.legacy_result!r} new={d.new_result!r}")
Run it:
python demo1_shadow.py
--- confirm the two implementations actually disagree at spend=2000 ---
legacy_loyalty.compute_tier(2000, 40) = Silver
new_loyalty.compute_tier(2000, 40) = Gold
--- shadow mode (rollout_percent=0) over a batch of synthetic customers ---
cust-001: spend=0 age=10 -> caller sees 'Bronze'
cust-002: spend=500 age=200 -> caller sees 'Silver'
cust-003: spend=1999 age=500 -> caller sees 'Silver'
LOG: DIVERGENCE key=cust-004 args=(2000, 500) kwargs={} legacy='Silver'/None new='Gold'/None
cust-004: spend=2000 age=500 -> caller sees 'Silver'
cust-005: spend=2001 age=500 -> caller sees 'Gold'
cust-006: spend=15000 age=900 -> caller sees 'Platinum'
cust-007: spend=15000 age=100 -> caller sees 'Gold'
cust-008: spend=750 age=30 -> caller sees 'Silver'
cust-009: spend=4000 age=1000 -> caller sees 'Gold'
LOG: DIVERGENCE key=cust-010 args=(2000, 1000) kwargs={} legacy='Silver'/None new='Gold'/None
cust-010: spend=2000 age=1000 -> caller sees 'Silver'
Total divergences logged (not shown to any caller): 2
key=cust-004 args=(2000, 500) legacy='Silver' new='Gold'
key=cust-010 args=(2000, 1000) legacy='Silver' new='Gold'
Two customers, cust-004 and cust-010, sit at exactly $2,000 of spend. Both of them see 'Silver', the legacy answer, because rollout_percent=0. But the facade also quietly logged a DIVERGENCE for both: legacy said Silver, the rewrite said Gold. Not a single customer was affected, and you now have concrete, reproducible proof that the rewrite disagrees with production at a specific, exact boundary. That is the value of shadow mode: the bug surfaces on its own schedule, from real inputs, before it costs anything.
Step 6: Resolve the Divergence, Not Just the Code
A divergence is a question, not automatically a bug. The next step is not “fix the code,” it is “find out which side is actually correct.” In a real migration this means checking with whoever owns the business rule. Here, imagine that conversation happened and the answer came back: the strict > is intentional. Customers at exactly $2,000 have always stayed Silver, and nobody wants to silently move them to Gold. Update new_loyalty.py to match legacy exactly:
def compute_tier(total_spend: float, account_age_days: int) -> str:
if account_age_days >= 365 and total_spend >= 10000:
return "Platinum"
if total_spend > 2000:
return "Gold"
if total_spend >= 500:
return "Silver"
return "Bronze"
Now re-run shadow mode, but at a scale a five-customer smoke test cannot give you. Generate 500 synthetic QA-style customers and push them all through:
import logging
import random
import sys
import legacy_loyalty
import new_loyalty
from strangler import Strangler
logging.basicConfig(level=logging.WARNING, format="LOG: %(message)s", stream=sys.stdout)
print("--- re-verify shadow mode after aligning new_loyalty to the legacy threshold ---")
random.seed(42)
strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=0,
)
# Synthetic QA data: spend is always a plausible non-negative amount.
# This is exactly the kind of data a QA fixture generator produces, and
# exactly the kind of data that never includes a refunded customer.
sample_size = 500
for i in range(sample_size):
customer_id = f"qa-cust-{i:04d}"
spend = round(random.uniform(0, 25000), 2)
age = random.randint(0, 2000)
strangler.handle(customer_id, spend, age)
print(f"Ran {sample_size} synthetic QA requests through shadow mode.")
print(f"Divergences found: {len(strangler.divergences)}")
python demo2_shadow_clean.py
--- re-verify shadow mode after aligning new_loyalty to the legacy threshold ---
Ran 500 synthetic QA requests through shadow mode.
Divergences found: 0
Zero divergences across 500 samples. That is a genuinely good sign, but notice the phrase “synthetic QA-style customers.” Every spend value in that batch is a plausible non-negative dollar amount, exactly the kind of input a QA fixture generator produces and exactly the kind of input that will not include a customer whose balance went negative because of a refund. Shadow mode is only as good as the traffic it sees, and a clean shadow-mode run does not mean a clean rewrite. It means the rewrite is clean on the inputs shadow mode happened to see.
Step 7: Start the Rollout and Find a Second Bug
With shadow mode clean, the natural next move is to route a real slice of traffic to the new implementation. Before doing that, though, imagine the team also decides to harden the rewrite: real production spend totals, they reason, should never be negative, so refunds should be normalized to their dollar magnitude rather than left negative. Update new_loyalty.py once more:
def compute_tier(total_spend: float, account_age_days: int) -> str:
normalized_spend = abs(total_spend)
if account_age_days >= 365 and normalized_spend >= 10000:
return "Platinum"
if normalized_spend > 2000:
return "Gold"
if normalized_spend >= 500:
return "Silver"
return "Bronze"
This change never went through shadow mode against real refund data (there was none in the synthetic batch), and it looks reasonable in isolation. Now set rollout_percent=50 and run it against traffic shaped like real production data, which, unlike the QA fixture, includes actual refunds:
import logging
import random
import sys
import legacy_loyalty
import new_loyalty
from strangler import Strangler, _bucket
logging.basicConfig(level=logging.WARNING, format="LOG: %(message)s", stream=sys.stdout)
print("--- the bug, called directly. No exception, just a wrong answer. ---")
print("legacy_loyalty.compute_tier(-600, 400) =", legacy_loyalty.compute_tier(-600, 400))
print("new_loyalty.compute_tier(-600, 400) =", new_loyalty.compute_tier(-600, 400))
print("(a customer with a $600 net refund balance is not a $600 big spender)")
print()
print("--- progressive rollout to 50 percent, against production-shaped traffic ---")
random.seed(7)
strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=50,
)
# Production traffic, unlike the QA fixture in demo2, includes real refunds:
# about 1 in 60 customers has a net-negative spend this month.
customers = []
for i in range(300):
customer_id = f"prod-cust-{i:04d}"
if random.random() < (1 / 60):
spend = round(-random.uniform(5, 900), 2)
else:
spend = round(random.uniform(0, 25000), 2)
age = random.randint(0, 2000)
customers.append((customer_id, spend, age))
results = {}
for customer_id, spend, age in customers:
results[customer_id] = strangler.handle(customer_id, spend, age)
refund_customers = [c for c in customers if c[1] < 0]
mis_tiered = [
(cid, spend, age)
for cid, spend, age in refund_customers
if _bucket(cid, strangler.salt) < strangler.rollout_percent and results[cid] != "Bronze"
]
print(f"Processed {len(customers)} production-shaped requests at rollout_percent=50.")
print(f"Refunded (negative-spend) customers in this batch: {len(refund_customers)}")
print(f"Divergences logged: {len(strangler.divergences)} (the facade compares BOTH implementations")
print("on every call, even for the half of traffic still routed to legacy -- that's what lets")
print("you keep watching new_fn's health for the whole rollout, not just at cutover.)")
print()
print("Refunded customers who were actually routed to new_fn this run, and what they were told:")
for cid, spend, age in refund_customers:
if _bucket(cid, strangler.salt) < strangler.rollout_percent:
print(f" {cid}: spend={spend:<8} -> caller saw {results[cid]!r} (should be 'Bronze')")
print()
print(f"Silently mis-tiered, real customer-visible impact, no exception ever raised: {len(mis_tiered)}")
python demo3_rollout.py
--- the bug, called directly. No exception, just a wrong answer. ---
legacy_loyalty.compute_tier(-600, 400) = Bronze
new_loyalty.compute_tier(-600, 400) = Silver
(a customer with a $600 net refund balance is not a $600 big spender)
--- progressive rollout to 50 percent, against production-shaped traffic ---
LOG: DIVERGENCE key=prod-cust-0041 args=(-748.83, 373) kwargs={} legacy='Bronze'/None new='Silver'/None
LOG: DIVERGENCE key=prod-cust-0102 args=(-720.26, 352) kwargs={} legacy='Bronze'/None new='Silver'/None
LOG: DIVERGENCE key=prod-cust-0189 args=(-718.94, 1532) kwargs={} legacy='Bronze'/None new='Silver'/None
Processed 300 production-shaped requests at rollout_percent=50.
Refunded (negative-spend) customers in this batch: 5
Divergences logged: 3 (the facade compares BOTH implementations
on every call, even for the half of traffic still routed to legacy -- that's what lets
you keep watching new_fn's health for the whole rollout, not just at cutover.)
Refunded customers who were actually routed to new_fn this run, and what they were told:
prod-cust-0189: spend=-718.94 -> caller saw 'Silver' (should be 'Bronze')
Silently mis-tiered, real customer-visible impact, no exception ever raised: 1
Called directly, side by side, the bug is obvious: a customer with a net -$600 balance from a refund is not a $600 big spender, but abs(-600) makes the rewrite think they are. Out of 300 production-shaped customers, 5 had a refund-driven negative balance, and 3 of those had a large enough refund to actually change their tier once normalized. Of those 3, only one, prod-cust-0189, happened to land in the “new” bucket at 50 percent rollout, and that customer really was told 'Silver' when the correct, legacy-matching answer is 'Bronze'.
This is a meaningfully worse bug than the first one. There is no exception anywhere in this call. compute_tier() returns a completely valid string; it is just the wrong one. A defensive try/except around new_fn, the kind of safety net that catches crashes, does nothing here, because nothing crashed. The only way to catch a silent wrong answer is to compare it against a known-good answer, which is exactly what the shadow-compare log is for. This is also why the divergence log shows all 3 affected customers, not just the one routed to new: the facade keeps comparing both implementations for every customer, including the ones still safely on legacy, so you can watch the rewrite’s real-world accuracy improve (or not) across the whole rollout, not just at the moment of cutover.
Step 8: Roll Back With the Kill Switch
The correct order of operations here is not “diagnose, then fix, then roll back if needed.” It is “roll back first, so the bleeding stops, then diagnose at your own pace.” That is what the kill switch is for. It is a separate control from rollout_percent on purpose: during an actual incident, the last thing you want is to also have to remember or recompute the right percentage to dial back to. Flip one flag, and every request goes to legacy, unconditionally:
import logging
import random
import sys
import legacy_loyalty
import new_loyalty
from strangler import Strangler
logging.basicConfig(level=logging.WARNING, format="LOG: %(message)s", stream=sys.stdout)
print("--- incident response: kill switch first, root cause second ---")
random.seed(7) # same seed as demo3: same customer batch, same routing
strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=50,
)
customers = []
for i in range(300):
customer_id = f"prod-cust-{i:04d}"
if random.random() < (1 / 60):
spend = round(-random.uniform(5, 900), 2)
else:
spend = round(random.uniform(0, 25000), 2)
age = random.randint(0, 2000)
customers.append((customer_id, spend, age))
for customer_id, spend, age in customers:
strangler.handle(customer_id, spend, age)
target = next(c for c in customers if c[0] == "prod-cust-0189")
before = strangler.handle(*target)
print(f"prod-cust-0189 right now, mid-incident (rollout_percent=50, kill_switch=False): {before!r}")
strangler.kill_switch = True
after = strangler.handle(*target)
print(f"prod-cust-0189 the instant kill_switch=True (rollout_percent is UNCHANGED, still 50): {after!r}")
print(f"legacy's own answer, for comparison: {legacy_loyalty.compute_tier(target[1], target[2])!r}")
print()
print("The kill switch is a separate control from rollout_percent on purpose: during an incident")
print("you don't want to also have to remember the right percentage to roll back to. One flag,")
print("checked before the bucket math even runs, sends 100% of traffic back to legacy.")
python demo3b_kill_switch.py
--- incident response: kill switch first, root cause second ---
LOG: DIVERGENCE key=prod-cust-0041 args=(-748.83, 373) kwargs={} legacy='Bronze'/None new='Silver'/None
LOG: DIVERGENCE key=prod-cust-0102 args=(-720.26, 352) kwargs={} legacy='Bronze'/None new='Silver'/None
LOG: DIVERGENCE key=prod-cust-0189 args=(-718.94, 1532) kwargs={} legacy='Bronze'/None new='Silver'/None
LOG: DIVERGENCE key=prod-cust-0189 args=(-718.94, 1532) kwargs={} legacy='Bronze'/None new='Silver'/None
prod-cust-0189 right now, mid-incident (rollout_percent=50, kill_switch=False): 'Silver'
LOG: DIVERGENCE key=prod-cust-0189 args=(-718.94, 1532) kwargs={} legacy='Bronze'/None new='Silver'/None
prod-cust-0189 the instant kill_switch=True (rollout_percent is UNCHANGED, still 50): 'Bronze'
legacy's own answer, for comparison: 'Bronze'
The kill switch is a separate control from rollout_percent on purpose: during an incident
you don't want to also have to remember the right percentage to roll back to. One flag,
checked before the bucket math even runs, sends 100% of traffic back to legacy.
prod-cust-0189 was seeing 'Silver' mid-incident. The instant kill_switch flips to True, with rollout_percent left completely untouched at 50, that same customer is back to 'Bronze', matching legacy exactly. In strangler.py, the kill switch is checked before the bucket math even runs ((not self.kill_switch) and _bucket(...) < self.rollout_percent), so there is no code path where a stale or forgotten rollout percentage can leak a single request to a rewritten implementation you have just decided you do not trust.
Step 9: Fix the Real Bug and Complete the Rollout
With the kill switch protecting customers, there is no time pressure to diagnose correctly. The actual fix here is not to add a better negative-number check; it is to remove the normalization entirely. A raw negative number already fails every threshold in the function (total_spend > 2000, total_spend >= 500) and falls through to 'Bronze' on its own, exactly like legacy. The bug was introduced by adding logic, and the fix is to delete it, not to add more:
def compute_tier(total_spend: float, account_age_days: int) -> str:
if account_age_days >= 365 and total_spend >= 10000:
return "Platinum"
if total_spend > 2000:
return "Gold"
if total_spend >= 500:
return "Silver"
return "Bronze"
(This is byte-for-byte the same file as Step 6’s fix. The correct v4 is identical to v2: once you stop trying to be clever about refunds, matching legacy takes zero extra code.)
Re-run the exact same 300-customer production batch at rollout_percent=50, then push to a full 100 percent:
import logging
import random
import sys
import legacy_loyalty
import new_loyalty
from strangler import Strangler
logging.basicConfig(level=logging.WARNING, format="LOG: %(message)s", stream=sys.stdout)
print("--- re-verify at 50 percent after the fix, then complete the rollout ---")
random.seed(7) # identical seed -> identical customer batch as demo3/demo3b
strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=50,
)
customers = []
for i in range(300):
customer_id = f"prod-cust-{i:04d}"
if random.random() < (1 / 60):
spend = round(-random.uniform(5, 900), 2)
else:
spend = round(random.uniform(0, 25000), 2)
age = random.randint(0, 2000)
customers.append((customer_id, spend, age))
for customer_id, spend, age in customers:
strangler.handle(customer_id, spend, age)
print(f"Divergences at rollout_percent=50 after the v4 fix: {len(strangler.divergences)} (zero expected)")
target = next(c for c in customers if c[0] == "prod-cust-0189")
print(f"prod-cust-0189, the customer the kill switch protected earlier, now at 50% live: {strangler.handle(*target)!r}")
print()
print("--- completing the rollout to 100 percent ---")
strangler.rollout_percent = 100
all_match_new = True
for customer_id, spend, age in customers:
tier = strangler.handle(customer_id, spend, age)
if tier != new_loyalty.compute_tier(spend, age):
all_match_new = False
print(f"All {len(customers)} customers now served by new_fn's answer: {all_match_new}")
print(f"Divergences across the whole rollout-to-100 run: {len(strangler.divergences)}")
python demo4_rollback_and_cutover.py
--- re-verify at 50 percent after the fix, then complete the rollout ---
Divergences at rollout_percent=50 after the v4 fix: 0 (zero expected)
prod-cust-0189, the customer the kill switch protected earlier, now at 50% live: 'Bronze'
--- completing the rollout to 100 percent ---
All 300 customers now served by new_fn's answer: True
Divergences across the whole rollout-to-100 run: 0
Zero divergences at 50 percent. prod-cust-0189, the customer the kill switch protected a moment ago, now correctly gets 'Bronze' from live-routed new traffic with no rollback needed. Pushing rollout_percent to 100 confirms every one of the 300 customers is now served by new_fn, with the divergence log still empty across the entire run. That is what “done” looks like for this migration slice: full traffic, zero disagreement, no kill switch required.
Step 10: Wire the Facade Into a Real Endpoint
Every demo so far has called strangler.handle() directly. In production you would put it behind an actual route, with rollout_percent and kill_switch read from a config service or environment variable rather than hardcoded, so you can change them without a redeploy. Create app.py:
"""Wiring the facade into a real HTTP endpoint.
This is the shape a reader would actually deploy: one FastAPI route,
backed by a module-level Strangler instance whose rollout_percent and
kill_switch you would normally flip via a config service or environment
variable, not by editing this file.
"""
from fastapi import FastAPI
import legacy_loyalty
import new_loyalty
from strangler import Strangler
app = FastAPI()
loyalty_strangler = Strangler(
legacy_fn=legacy_loyalty.compute_tier,
new_fn=new_loyalty.compute_tier,
salt="loyalty-tier-migration",
rollout_percent=100, # fully cut over, per the demos above
kill_switch=False,
)
@app.get("/customers/{customer_id}/loyalty-tier")
def get_loyalty_tier(customer_id: str, total_spend: float, account_age_days: int):
tier = loyalty_strangler.handle(customer_id, total_spend, account_age_days)
return {"customer_id": customer_id, "tier": tier}
Exercise it with FastAPI’s own test client, which drives real requests through the real ASGI app in-process:
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
print("--- the same facade, called over real HTTP through the actual FastAPI app ---")
response = client.get(
"/customers/http-cust-01/loyalty-tier",
params={"total_spend": 2000, "account_age_days": 500},
)
print(f"GET /customers/http-cust-01/loyalty-tier?total_spend=2000&account_age_days=500")
print(f" status: {response.status_code}")
print(f" body: {response.json()}")
response = client.get(
"/customers/http-cust-02/loyalty-tier",
params={"total_spend": -718.94, "account_age_days": 1532},
)
print(f"GET /customers/http-cust-02/loyalty-tier?total_spend=-718.94&account_age_days=1532")
print(f" status: {response.status_code}")
print(f" body: {response.json()}")
python demo5_http.py
--- the same facade, called over real HTTP through the actual FastAPI app ---
GET /customers/http-cust-01/loyalty-tier?total_spend=2000&account_age_days=500
status: 200
body: {'customer_id': 'http-cust-01', 'tier': 'Silver'}
GET /customers/http-cust-02/loyalty-tier?total_spend=-718.94&account_age_days=1532
status: 200
body: {'customer_id': 'http-cust-02', 'tier': 'Bronze'}
Both requests return 200 with the same tiers the direct function calls produced. The routing, comparison, and fallback logic inside strangler.py did not need to change at all to work behind an HTTP layer; only the caller changed.
Step 11: Remove the Legacy Path
The Azure Architecture Center’s own description of this pattern ends with a fourth phase most tutorials skip: “You remove the facade and reconfigure the client app to communicate directly with the new system.” A strangler facade is deliberately transitional architecture. Once rollout_percent has been at 100 with a clean divergence log for long enough that you trust it, the honest final step is to delete the facade, not leave it running forever as an unnecessary indirection:
@app.get("/customers/{customer_id}/loyalty-tier")
def get_loyalty_tier(customer_id: str, total_spend: float, account_age_days: int):
tier = new_loyalty.compute_tier(total_spend, account_age_days)
return {"customer_id": customer_id, "tier": tier}
strangler.py, legacy_loyalty.py, and the comparison logging all disappear from the live path. If you want to keep the old implementation around briefly as a documented fallback for legacy API clients, Azure’s own guidance explicitly allows that as a deliberate choice, not something the pattern requires you to avoid.
Common Mistakes and Gotchas
A Silent Wrong Answer Is Not the Same Risk as an Exception
The facade’s try/except around new_fn only protects against crashes. It did nothing for the abs() bug in Step 7, because nothing raised. Do not treat exception handling as a substitute for comparing actual outputs; they catch two different failure modes.
The Facade Itself Is a New Single Point of Failure
Azure’s own pattern documentation calls this out directly: “Make sure that the facade doesn’t become a single point of failure or a performance bottleneck.” Every request now depends on the facade being up, in addition to whichever backend answers it. Keep the facade thin, and monitor it as its own service, not as an invisible detail of whichever system happens to be handling traffic that week.
Dual Writes Need a Different Pattern Than This One
This tutorial deliberately migrated a read-only lookup with no side effects. If your migration slice writes data (creating an order, updating a balance), running both implementations on every call means writing to two places, and now you have to reconcile them if they disagree, which is a substantially harder problem than comparing two return values. Choosing your first migration slice carefully matters as much as the routing mechanism itself, and a read-only capability with no side effects and few dependencies is exactly the kind of slice worth choosing first. Save dual-write and data-ownership problems for later slices, once the team has practiced the traffic-routing mechanics on something safer.
Shadow Mode’s Sample Is Only as Good as Its Traffic
500 clean synthetic customers in Step 6 found zero divergences and still missed a real bug. If your QA or staging traffic does not include the same messy edge cases production does (refunds, deleted accounts, malformed historical records), a clean shadow-mode run will give you false confidence. Where possible, shadow-test against a sample of real (anonymized) production traffic, not just generated fixtures.
Don’t Remove the Comparison Logging the Moment You Start Routing Live Traffic
It is tempting to think shadow mode’s job is done once rollout_percent moves above 0. As Step 7 showed, the opposite is true: the facade in this tutorial keeps comparing both implementations for 100 percent of traffic all the way through the rollout, which is exactly what caught the second bug before it reached more than one customer.
Step 12: Verify the Facade’s Guarantees With pytest
The demos above prove the facade behaves correctly on the specific scenarios you walked through by hand. A test suite pins down the guarantees themselves, deterministically, without needing to get lucky with a random seed. Create test_strangler.py:
import pytest
from strangler import Strangler, _bucket
def test_bucket_is_deterministic_across_calls():
assert _bucket("customer-123", "salt-a") == _bucket("customer-123", "salt-a")
def test_bucket_distribution_changes_with_salt():
# A different salt should reshuffle bucket assignment for the same key,
# so two unrelated strangler migrations don't accidentally correlate.
a = _bucket("customer-123", "migration-a")
b = _bucket("customer-123", "migration-b")
assert a != b
def test_shadow_mode_never_returns_new_fns_answer():
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=lambda x: "new-answer",
salt="test",
rollout_percent=0,
)
for key in ["a", "b", "c", "d", "e"]:
assert strangler.handle(key, 1) == "legacy-answer"
def test_shadow_mode_logs_divergence_without_surfacing_it():
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=lambda x: "new-answer",
salt="test",
rollout_percent=0,
)
result = strangler.handle("some-key", 1)
assert result == "legacy-answer"
assert len(strangler.divergences) == 1
assert strangler.divergences[0].legacy_result == "legacy-answer"
assert strangler.divergences[0].new_result == "new-answer"
def test_no_divergence_logged_when_results_agree():
strangler = Strangler(
legacy_fn=lambda x: x * 2,
new_fn=lambda x: x * 2,
salt="test",
rollout_percent=0,
)
strangler.handle("some-key", 21)
assert len(strangler.divergences) == 0
def test_full_cutover_returns_new_fns_answer():
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=lambda x: "new-answer",
salt="test",
rollout_percent=100,
)
for key in ["a", "b", "c", "d", "e"]:
assert strangler.handle(key, 1) == "new-answer"
def test_kill_switch_overrides_a_full_cutover():
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=lambda x: "new-answer",
salt="test",
rollout_percent=100,
kill_switch=True,
)
assert strangler.handle("any-key", 1) == "legacy-answer"
def test_new_fn_exception_falls_back_to_legacy_when_routed_to_new():
def broken_new(_x):
raise ValueError("boom")
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=broken_new,
salt="test",
rollout_percent=100,
)
# even fully cut over, a broken new_fn must never reach the caller
assert strangler.handle("any-key", 1) == "legacy-answer"
def test_legacy_exception_propagates_when_routed_to_legacy():
def broken_legacy(_x):
raise RuntimeError("legacy is down")
strangler = Strangler(
legacy_fn=broken_legacy,
new_fn=lambda x: "new-answer",
salt="test",
rollout_percent=0,
)
with pytest.raises(RuntimeError, match="legacy is down"):
strangler.handle("any-key", 1)
def test_same_key_routes_consistently_across_repeated_calls():
calls = []
def counting_new(_x):
calls.append("new")
return "new-answer"
strangler = Strangler(
legacy_fn=lambda x: "legacy-answer",
new_fn=counting_new,
salt="test",
rollout_percent=50,
)
results = {strangler.handle("sticky-customer", 1) for _ in range(20)}
# 20 calls with the same key must all land on the same side of the line
assert len(results) == 1
Run it:
python -m pytest test_strangler.py -v
============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- C:\Claude\Cowork\Sxz.io\sxz-automations\tmp\strangler_fig\venv_clean\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\Claude\Cowork\Sxz.io\sxz-automations\tmp\strangler_fig
plugins: anyio-4.15.1
collecting ... collected 10 items
test_strangler.py::test_bucket_is_deterministic_across_calls PASSED [ 10%]
test_strangler.py::test_bucket_distribution_changes_with_salt PASSED [ 20%]
test_strangler.py::test_shadow_mode_never_returns_new_fns_answer PASSED [ 30%]
test_strangler.py::test_shadow_mode_logs_divergence_without_surfacing_it PASSED [ 40%]
test_strangler.py::test_no_divergence_logged_when_results_agree PASSED [ 50%]
test_strangler.py::test_full_cutover_returns_new_fns_answer PASSED [ 60%]
test_strangler.py::test_kill_switch_overrides_a_full_cutover PASSED [ 70%]
test_strangler.py::test_new_fn_exception_falls_back_to_legacy_when_routed_to_new PASSED [ 80%]
test_strangler.py::test_legacy_exception_propagates_when_routed_to_legacy PASSED [ 90%]
test_strangler.py::test_same_key_routes_consistently_across_repeated_calls PASSED [100%]
============================= 10 passed in 0.03s ==============================
Ten tests, ten guarantees: bucket assignment is deterministic and salt-scoped, shadow mode never leaks the new answer to a caller while still logging divergence, agreement produces no log noise, a full cutover actually uses the new answer, the kill switch overrides even a 100 percent rollout, a broken new_fn falls back to legacy instead of propagating, a broken legacy_fn still propagates when nothing is protecting it, and repeated calls for the same key stay on the same side of the line every time.
How to Confirm It All Works End to End
Run the whole sequence once more, in order, from a clean checkout, and confirm each of these:
python demo1_shadow.pywith the Step 3 version ofnew_loyalty.pylogs exactly 2 divergences, both at spend=2000, and every caller still sees legacy’s answer.python demo2_shadow_clean.pywith the Step 6 fix logs 0 divergences across 500 synthetic customers.python demo3_rollout.pywith the Step 7 “normalized” version logs at least one real customer being told the wrong tier with no exception raised.python demo3b_kill_switch.pyshows that same customer’s answer flip from wrong to correct the instantkill_switch=Trueis set, withrollout_percentunchanged.python demo4_rollback_and_cutover.pywith the real fix in place logs 0 divergences at 50 percent and confirms all customers get the new answer at 100 percent.python -m pytest test_strangler.py -vpasses all 10 tests.
If all six hold, you have built, broken, diagnosed, rolled back, fixed, and completed a full Strangler Fig migration, and you have a reusable facade for the next one.
Next Steps
- Read How to Build a Feature Flag System in Python With Sticky Percentage Rollouts for a deeper look at the bucketing technique this facade reuses, including a real reproduced bug from getting the hashing wrong.
- Read How to Use Differential Testing to Safely Replace Legacy Code in Python to go deeper on systematically comparing two implementations, including at higher volume than a single shadow-mode run.
- Read How to Safely Refactor Legacy Python Code Using Characterization Tests for the technique that locks in legacy behavior before you even start writing a replacement.
- Read How to Prepare a Legacy Application for Migration Using Seams and Adapters in Python for the structural refactoring that creates the boundary a strangler facade needs to attach to in the first place.
- Once you have run one migration slice through this pattern, pick a second, ideally one that touches data, and work through what changes when the capability is not read-only.








No Comment! Be the first one.