Feature Flags in Production Migration

ModernLift · ·7 min read
Part 5 of 8

In a production migration, feature flags decide which system serves each request — legacy or the modernized slice — at runtime, without a deploy. They let you ramp traffic to a proven slice gradually, hold at any percentage, and roll back by flipping a value. The flag turns a validated slice into a controllable, reversible promotion.

Part 4 ended with a proven slice: outputs compared, state reconciled, every divergence accounted for. The evidence says it’s safe to carry traffic. It doesn’t carry it. Something has to decide, on each live request, whether the legacy path or the new slice answers — and that decision has to be changeable in seconds, not in a deploy. That control surface is the feature flag, and in a migration it does a job quite different from the product toggles the term usually evokes.

What a migration flag controls

A product feature flag answers “does this user get the new checkout button?” A migration flag answers a stranger question: “for this request, does the legacy implementation or the modernized implementation produce the response?” — where both are supposed to produce the same response, because Part 3 proved they do.

That difference matters. The two paths are not a new feature and its absence; they are two implementations of the same behavior, running side by side behind the strangler facade from Part 2. The flag is the switch on that facade. When it routes a request to legacy, the user gets exactly what they got yesterday. When it routes to the slice, they get the proven equivalent. The whole point is that they can’t tell which — and that you can change the answer without shipping code.

This is what makes a validated slice into a promotable one. Parity evidence tells you the slice is safe; the flag is the mechanism that lets you act on that evidence incrementally and undo it instantly.

Ramp, don’t switch

The temptation, once a slice is proven, is to flip it to 100% and move on. Resist it. Shadow traffic (Part 2) ran the slice in parallel without the user seeing it; live traffic is the first time the slice’s output is the one returned and its writes are the ones that stick. However good the parity evidence, the ramp is where you find what shadowing couldn’t: real downstream consumers reacting to the slice’s actual responses, real load on its real datastore, the interaction effects no parallel run reproduces.

So you ramp:

StageTraffic to sliceWhat you’re watching for
CanaryA small initial slice of trafficFirst live responses, error rate, latency, divergence alerts
RampStepped increaseStability holding as volume grows; downstream behaving
MajorityMost traffic on the sliceTail cases, periodic jobs, off-peak patterns
FullAll traffic; legacy idleA clean settle window before decommissioning legacy

At each step you hold long enough to see the system’s real rhythms — including, again, the periodic behavior that only appears at month-end or close. You advance on green signals, not on a calendar. And critically, the comparison from Part 4 keeps running during the ramp. A slice that matched perfectly in shadow can still diverge under live conditions; continuing to compare turns the ramp itself into a final validation gate rather than a leap of faith.

Sticky assignment and the consistency trap

A subtlety that bites migration flags specifically: if the flag decides per-request at random, the same user can hit the legacy path on one request and the slice on the next. When the two implementations share state, or when a user’s session spans several requests, that flicker can produce incoherent behavior — a record created by one implementation and read by the other, mid-workflow, before reconciliation has settled.

The fix is sticky assignment: hash a stable key — user ID, account, tenant — so a given entity consistently lands on the same side for the duration of a ramp stage. A user who’s on the slice stays on the slice. This keeps each entity’s experience internally consistent and makes divergence reports legible (you know which population was on which path). For genuinely stateless, independent requests, per-request routing is fine; the moment requests share state or sequence, stickiness is not optional.

Where the flag is evaluated

A migration flag belongs at the routing layer — the strangler facade — not scattered through application code. The reasons are about control and reversibility:

  • One place to change. A single evaluation point means the ramp and the rollback are one configuration change, not a coordinated edit across services.
  • In front of both paths. Evaluated at the facade, the flag sits ahead of both implementations, so neither needs to know it’s being shadowed or ramped.
  • Clean rollback. A routing-layer flag rolls back by re-pointing traffic. An in-application flag scatters the dead-path logic into the very code you’re trying to retire, and makes “are we fully off legacy?” a harder question to answer with confidence.

In-application flags have their place for fine-grained toggles within a slice. But the slice-vs-legacy decision — the one that has to be instantly reversible — lives at the routing seam.

The flag is also your rollback

This is the property that makes the whole approach safe, and it’s worth stating directly: because the legacy path stays live and warm throughout the ramp, rolling back is setting the flag’s value, not unwinding work. If the slice misbehaves at 20% traffic, you set it to 0% and every request is back on legacy in the time it takes config to propagate. No redeploy, no database restore, no war room. The worst case is a config change, not a 2am page.

That only holds if you preserve the preconditions. Rollback is instant if the legacy path is still operational, if no irreversible state migration has happened underneath the flag, and if the flag’s evaluation path is itself reliable. Lose any of those and the flag’s promise quietly evaporates. Part 7 of this series is entirely about engineering rollback so it stays as cheap as it sounds; the flag is the trigger, but a trigger is only as good as the safety behind it.

Flag debt: the part everyone skips

A migration creates flags by the dozen — one per slice, sometimes more. Each one is, by design, temporary: it exists to manage a ramp, and once the slice is fully cut over and the legacy path is dead, the flag’s decision is permanent. Left in place, it becomes flag debt:

  • Every stale flag is an untested branch — a dead legacy path still wired in, still a config change away from being re-activated by accident.
  • A pile of permanently-true flags makes the codebase lie about its own structure; nobody can tell which branches are live.
  • A misconfigured stale flag is a footgun: someone re-enables a retired legacy path months later and reintroduces a bug you fixed.

The discipline is to treat flag removal as part of finishing a slice, not as cleanup for later. When a slice reaches full traffic and settles, you remove the flag and delete the dead legacy code path it guarded. The slice isn’t done when it’s at 100% — it’s done when the flag and the old path are gone. Carrying that debt forward is how a clean migration slowly turns back into the tangle you were escaping.

Flags are not free

Feature flags add a real layer — a routing decision and a configuration system on the hot path of every request. That layer can fail: a flag service outage, a bad config push, latency in evaluation. The mitigations are standard but non-negotiable — sane defaults so a flag-system failure fails to a known-good path (usually legacy, during a migration), local caching so an evaluation outage doesn’t stall requests, and audit logging so every flag change is attributable. And there’s an irreducible cost in complexity: more moving parts, more to reason about, more to test. The flag earns that cost by making promotion reversible. On a slice small and low-risk enough that a direct, well-rehearsed cutover is genuinely safe, a heavyweight flagging apparatus can be more machinery than the risk warrants. Match the control surface to the stakes.

Where this leads

The flag controls which slice serves traffic. But a slice has to be built, tested, and shipped before there’s anything to route to — repeatedly, reliably, for a system that may have had no automated pipeline at all. Part 6, CI/CD for Legacy Modernization, is about that engine: how you stand up continuous integration and delivery around a legacy system mid-migration, so every slice arrives validated and ready for the flag to ramp.

Frequently asked questions

How are migration feature flags different from ordinary feature flags?
Ordinary flags toggle a feature on or off for a user segment. Migration flags route the same request between two implementations that must behave identically, often with percentage ramps and sticky assignment. They control infrastructure-level traffic shifting, not product features, and they're temporary — every one is meant to be removed after cutover.
Where should the flag be evaluated — in the application or at the routing layer?
For slice migration, evaluate at the strangler facade or routing layer, so the decision sits in front of both implementations and a single point controls the shift. In-application flags suit finer-grained toggles but scatter the routing logic and make a clean rollback harder to guarantee.
What is flag debt and why does it matter in a migration?
Flag debt is the accumulation of stale flags left in the code after their decision is permanent. In a migration you create a flag per slice, so debt builds fast. Each flag is an untested branch and a config footgun. The discipline is to retire every flag — and delete the dead legacy path — once a slice is fully cut over.
All 8 parts of Parity Validation & Safe Delivery →