Most strangler-fig migrations don’t fail at the new service. They fail at the routing layer, weeks in, when someone asks a simple question: which requests went to the new implementation, and can we undo it? Nobody can answer, because the façade was built as a one-off proxy and the routing rules were never written down.
The fix is to stop treating a traffic slice as a number. A slice is a contract with four clauses: routing key, stickiness, rollback trigger, and per-slice telemetry. Write those down before the first request moves, and the façade becomes the enforcement point for a decision instead of a place where decisions go to hide.
Why “10% to the new service” is not a routing decision
The mechanics of the pattern are well documented. Microsoft’s architecture guidance describes a façade that intercepts requests to the legacy back end and routes them either to the legacy application or to new services, shifting traffic incrementally as functionality migrates. AWS’s prescriptive guidance describes the same proxy layer, routing by API URL, with an anti-corruption layer translating calls between old and new interfaces.
What neither the percentage nor the diagram tells you is which 10%. Path-based routing and percentage-based routing are different instruments with different failure modes, and teams routinely configure one while describing the other. Istio’s traffic-shifting documentation is explicit that weight-based routing redirects a percentage of traffic between destinations, and that the two versions can scale independently without affecting the distribution. That is a statement about stateless request routing. It says nothing about whether a caller’s second request lands on the same implementation as the first.
That gap is where migrations get hurt. A percentage is an implementation detail of the façade. The contract is what the slice promises.
The four clauses of a slice routing contract
1. Routing key: what the façade actually keys on
Pick one and write it down per slice: path, method, header, tenant ID, account ID, or a combination. The key determines what rollback even means. If you route by path, rollback is a config change. If you route by tenant, rollback is a config change plus a decision about in-flight state for the tenants you moved back.
This is also the clause that makes your migration’s real API surface visible. During the transition, the routing rules are a public contract whether or not you documented them. If a consumer can influence routing through a header, that header is now part of your interface.
2. Stickiness: percentage splits assume statelessness
Legacy endpoints often aren’t stateless. Sessions, carts, multi-step checkout flows, and anything with a server-side cursor can break when consecutive requests land on different implementations.
Argo Rollouts’ concepts documentation is unusually blunt about this: not all applications can have different versions running in parallel at the same time, which is what canaries do, and this can be a showstopper for legacy applications. It also notes that Argo Rollouts doesn’t control traffic flow for connections it doesn’t understand, such as binary or queue channels.
So the stickiness clause has to answer: for this slice, how does a caller stay on one side for the duration of a request flow? Options include keying on a stable identifier (tenant, account, session), pinning by header set at the edge, or declining to slice at all and migrating the endpoint atomically. “We’ll figure it out” is not one of the options.
3. Rollback trigger: the signal and the threshold, agreed in advance
Google’s SRE Workbook defines canarying as a partial and time-limited deployment of a change and its evaluation, and lists two required capabilities: a method to deploy the change to a subset of the population, and an evaluation process to judge whether the change is good or bad. The evaluation is not optional infrastructure. It is half the definition.
Before a slice ships, name the signal (error rate delta, latency percentile, a business metric), the threshold that flips it back, and who has authority to flip it. Then verify the legacy path is still warm and reachable at that moment. A rollback trigger that points at a legacy deployment you scaled to zero last month is a decoration.
This is also where the façade’s own reliability matters. Both Microsoft and AWS flag that the proxy layer can become a single point of failure or a performance bottleneck. If the thing that routes your rollback is the thing that’s degraded, your rollback plan is theoretical.
4. Per-slice telemetry: aggregate metrics will lie to you
The SRE Workbook makes a point that every team running a canary discovers the hard way: because the canary population is a small subset of production, its errors are diluted in the overall error rate, and the canary evaluation is difficult to see and track compared to the overall traffic trend. The fix it describes is breaking metrics out by version so canary and control can be compared directly.
For a strangler migration, version isn’t the right dimension. Slice is. Emit the routing decision as a first-class event, tagged with slice ID, destination (legacy or new), routing key value, and outcome. Then you can answer “how is slice 3 doing” without reconstructing it from three dashboards and a guess.
{
"event": "route.decision",
"slice": "orders.read.v2",
"destination": "new",
"routing_key": {"type": "tenant", "value": "t_8842"},
"sticky": true,
"latency_ms": 41,
"status": 200,
"contract_version": "2026-10-01"
}
The contract_version field is the cheap part and the one you’ll thank yourself for. When the routing rules change, the telemetry tells you which requests were governed by which version of the contract.
Before and after: the same slice, two ways
A typical first attempt at a slice config, using weight-based routing:
# before: a percentage with no contract
route:
- destination: legacy
weight: 90
- destination: new
weight: 10
Nothing here says which requests, whether they stick, what flips it back, or how you’ll tell whether it’s working. The same slice with the contract made explicit:
# after: a slice with a written contract
slice: orders.read.v2
routing_key:
type: tenant
source: header X-Tenant-Id
allowlist: [t_8842, t_9107, t_9311]
stickiness:
mode: tenant-pinned
ttl: request-flow
rollback:
signal: error_rate_delta_vs_control
owner: orders-platform
legacy_path_verified: true
telemetry:
emit: route.decision
dimensions: [slice, destination, routing_key, contract_version]
owner: orders-platform
exit_criteria: legacy route receives zero requests for 14 days
The second version is longer. It is also the only one that can be reviewed, audited, or rolled back by someone who wasn’t in the room when it was written.
Feature flags and the façade are not the same instrument
Feature flags are a legitimate part of this toolkit. OpenFeature’s documentation describes flags as runtime-evaluated switches that alter application behavior without deploying new code, and lists canary releases — rolling out to an initially small subset of users — as a standard use case.
The distinction that matters: a flag decides behavior inside a service. The façade decides which service receives the request. Teams sometimes use flags to simulate routing, which works until the request never reaches the code that evaluates the flag. Be explicit about which layer owns the routing decision for each slice, and don’t let both layers claim it.
Governance: the temporary façade that becomes permanent
Martin Fowler’s original write-up names the cost directly: people often balk at building transitional architecture to let the new and legacy systems coexist, code that will go away once modernization is complete. The bargain is real — reduced risk and earlier value in exchange for infrastructure you intend to delete.
The bargain only holds if someone is responsible for the deletion. Two rules keep it honest:
- One owner per slice. Named in the contract, not implied by team structure.
- An exit criterion written before the slice ships. “Legacy route receives zero requests for N days, no consumers or reporting jobs still read from it, rollback window closed” is a criterion. “When we’re confident” is not.
Without those, the façade accumulates routing rules nobody can safely remove, and the transitional architecture quietly becomes the permanent one — with none of the design review a permanent system would have received.
The artifact: a one-page slice routing contract
Fill this in before each slice ships. It fits on a page, it goes in the repo next to the routing config, and it is the thing you point at when someone asks why a request went where it went.
SLICE ROUTING CONTRACT
Slice ID:
Owner:
Status: proposed | active | draining | retired
1. ROUTING KEY
Type: path | method | header | tenant | account | composite
Source:
Allowlist/rule:
2. STICKINESS
Mode: stateless | tenant-pinned | session-pinned | atomic (no split)
TTL:
Rationale:
3. ROLLBACK
Signal:
Threshold:
Authority:
Legacy path verified reachable: yes | no
Last verified:
4. TELEMETRY
Event emitted:
Dimensions: slice, destination, routing_key, contract_version
Dashboard:
5. EXIT CRITERIA
Legacy traffic zero for:
Downstream consumers checked:
Rollback window closed on:
6. NOTES
Known stateful edge cases:
Cross-system dependencies:
The contract doesn’t make the migration easy. It makes each step reviewable, reversible, and finishable — which is the part the percentage never gave you.
FAQ
Can we skip the contract for small, obviously stateless endpoints?
You can shorten it, but write down the routing key and the exit criterion at minimum. “Obviously stateless” is a claim someone should be able to check later, and the exit criterion is what stops the route from lingering.
What if the legacy service can’t be modified to support an anti-corruption layer?
That constrains the migration shape, not the contract. You can still route at the edge and translate at the new service’s boundary. What you lose is the ability to redirect internal calls from inside the monolith, which changes which slices are feasible — worth knowing before you pick the first one.
How do we handle a slice that spans multiple endpoints?
Treat the slice as the unit, not the endpoint. The routing key and stickiness clause apply to the whole flow, and the exit criterion covers every route in it. Slicing per endpoint inside a multi-step flow is how you get a caller whose first request hit the new service and whose second hit the legacy one.
Where does the contract live?
Next to the routing configuration, versioned with it. If the config and the contract can drift independently, they will.