Modernizing Legacy Enterprise Systems with the Strangler Fig Pattern
A phased modernization strategy that replaces legacy systems incrementally, reducing migration risk while allowing new capabilities to be delivered without disrupting existing operations.
Case study overview
Modernizing Legacy Enterprise Systems with the Strangler Fig Pattern
A note before this one: the scenario below is a composite, drawn from patterns that show up repeatedly across incremental legacy migrations rather than one specific engagement. The system, the module names, and some of the specifics are illustrative. The reasoning and trade-offs are the ones I'd actually stand behind.
Business Context
Picture a mid-sized healthcare organization running scheduling, patient records, and billing off a single .NET application that's been in production for over a decade. It works. Clinical staff use it every day to book appointments, pull up patient history, and reconcile insurance claims. The problem isn't that it's broken, it's that it's frozen. Every new capability the business wants, patient self-scheduling, real-time insurance eligibility checks, a mobile-friendly intake flow, has to be bolted onto a codebase where touching one module risks breaking three others.
The business goal wasn't "modernize the architecture." It was: ship new patient-facing features on a normal release cadence, without a multi-week freeze or a weekend cutover that risks the systems clinical staff rely on to do their jobs. In a system tied to active patient care, "we'll take it down for a weekend to migrate" isn't really an option anyone signs off on. That constraint, more than any technology preference, is what shaped everything downstream.
Technical Problem
Strip away the business framing and the technical problem is narrower: the monolith had no seams. Scheduling, billing, and patient records shared a data layer and a deployment pipeline, so a change to how appointments were booked could quietly affect how a claim got billed, because both paths touched the same tables and the same shared service classes. Deployments were all-or-nothing. Fixing a bug in one corner meant redeploying, and re-testing, the whole system.
There wasn't a single latency number or throughput target driving the redesign. The real technical bottleneck was blast radius: any change, however small, carried the risk of the entire system, because nothing in the architecture drew a boundary around it. The goal wasn't to make any one thing faster. It was to make it possible to change one part of the system without having to reason about all of it.
Requirements
What the solution had to satisfy, once the business and technical framing were both on the table:
Functional
- Extract capability from the monolith incrementally, module by module, rather than in one migration event
- Let legacy and new code paths run side by side during the transition, serving real traffic from both
- Support routing a subset of traffic to the new path before committing fully
Non-functional
- No downtime during any part of the migration
- A rollback path that didn't require a redeploy
- An audit trail for patient data that held up through the transition, not just after it
- A migration shaped for a team that could only work on it part time, alongside regular feature work, which meant it had to be safe to pause between modules
Constraints
Two constraints did most of the work in ruling out the obvious alternative. The system had to stay available; there was no maintenance window big enough, or acceptable enough, to take a system used for active patient care offline. And the team was small relative to the size of the legacy system, small enough that building a full replacement and running it alongside the original, in parallel, until it was ready to cut over, wasn't realistic. Compliance requirements around patient data added a third: whatever the migration path looked like, it had to preserve an audit trail the whole way through, not just once the new system was in place.
Put together, those constraints didn't just favor an incremental approach. They ruled out anything else.
Architecture Overview
The shape of the solution followed from those constraints almost mechanically. An API gateway sits in front of the legacy monolith, call it ClinCore, and initially routes all traffic straight through to it, unchanged. The first module pulled out is AppointmentScheduler, built as an independent service with its own deployment pipeline and its own data store for new appointments.
Once AppointmentScheduler exists, the gateway can route scheduling requests to either ClinCore or the new service, controlled by a feature flag rather than a deploy. Traffic starts at zero percent to the new service, moves to a small internal cohort, then a small percentage of real traffic, and increases as confidence grows.
Client
│
▼
API Gateway ──flag: scheduling──► AppointmentScheduler (new)
│
└───────────────────────────────► ClinCore (legacy monolith)
The same pattern repeats for each subsequent module, eligibility checks next, then billing. Over time, ClinCore shrinks to whatever hasn't been extracted yet, and eventually to nothing. The gateway is the only component that has to know the migration is happening at all; every client, internal or external, keeps calling the same endpoints it always did.
Key Decisions (Chosen vs. Rejected)
The API gateway as a facade, rather than updating every client to call new services directly, was the first real decision, and it's the one everything else depends on. Clients, whether that's the patient-facing scheduling app or an internal billing job, don't need to know a migration is happening. All the routing complexity lives in one place, which also means there's exactly one place to look during an incident, instead of tracing the problem across a dozen client integrations.
The second decision was routing at the feature-flag level instead of at the infrastructure level, DNS changes or load balancer rules pointed at a new upstream. Feature flags gave per-request control: route ten percent of scheduling traffic, or a specific internal cohort, without a deploy and without waiting for DNS to propagate. That granularity mattered more than it sounds like it should. It's the difference between finding a problem with ten percent of traffic affected and finding it with all of it.
The third decision was extraction order. Scheduling went first, not billing, not patient records, even though scheduling wasn't necessarily the highest-value module to modernize. It was the lowest-risk one to get wrong. Its data is mostly append-only, appointments get created and occasionally cancelled, and a mistake there is inconvenient rather than dangerous. Billing and patient records carry compliance weight that scheduling doesn't. Proving the pattern worked, and finding the gaps in the tooling and the process, on the module where a mistake costs the least, was worth more than starting with the module that would have delivered the flashiest result.
The alternative that got the most serious consideration, and got rejected, was a big-bang rewrite: build the new system in full, then cut over once. Given the constraints, no downtime tolerance, a team too small to run two full builds at once, that path concentrated all the risk into a single event with no partial rollback if something went wrong. The strangler approach costs more calendar time. It doesn't cost a single point of catastrophic failure.
Trade-offs
None of this is free. Running ClinCore and the extracted services in parallel means two systems to patch, monitor, and reason about, at least for as long as the migration takes, which for a system this size is measured in months, not weeks. That's real infrastructure cost and real cognitive load on a small team that's also still shipping regular feature work.
Feature-flag routing adds its own tax. Flags accumulate. Six months into a migration like this, someone has to remember which flags are still load-bearing and which ones can be deleted, and debugging a production issue means first figuring out which code path a given request actually took before you can figure out why it broke.
The migration is also slower, module by module, than a rewrite would have been if the rewrite had gone cleanly. That's the trade being made deliberately: speed for the ability to stop, or reverse, after any single step without having exposed the whole system to the risk at once. Whether that trade is worth it depends entirely on how much downtime, or how much blast radius, the organization can actually tolerate. In this case, given what the system supported, it wasn't a close call.
Failure & Resilience
The recovery path for a bad release on the new side was designed to be fast: flip the feature flag back toward ClinCore, no redeploy required, and traffic reverts within seconds. That part works well, and it's the reason feature flags were chosen over infrastructure-level routing in the first place.
What's easy to underdesign, and what I think most teams doing this kind of migration underdesign at least once, is what happens to data the new service already wrote before the flag gets flipped back. A feature flag reverts where new requests go. It doesn't undo what already happened. For scheduling, where the new service's data is mostly new appointment records, that gap is tolerable, worst case a handful of appointments need manual reconciliation, and that's a known, bounded cost. For a module like billing, the same gap left unaddressed would be a much more serious problem, because the cost of an unreconciled write isn't a scheduling inconvenience, it's a financial or compliance one.
The pattern that emerged, after getting this wrong once with scheduling, was to treat "what happens to writes already made in the new path if we roll back" as a question that has to be answered explicitly before extracting each module, not an assumption that the flag is a full undo button.
Operational Considerations
Observability had to change before the migration could really be trusted. Without a way to see, per request, which path it took, legacy or new, debugging a production issue meant guessing. Correlation IDs threaded through the gateway and both systems solved most of that, but it wasn't in place from day one, and the first few weeks of running dual paths without it were harder than they needed to be.
On-call surface area grew. It's not just ClinCore and AppointmentScheduler that someone has to understand during an incident now, it's the gateway routing logic connecting them, which is its own small but critical piece of the system that's easy to treat as plumbing until it's the thing that's broken.
Cost is real too. Running duplicate infrastructure for months rather than weeks adds up, and it's a cost that's easy to underestimate when the migration is being pitched as "temporary." Independent scaling turned out to be a genuine operational win once AppointmentScheduler was fully extracted; scheduling traffic spikes at different times than billing traffic does, and being able to scale one without the other is the kind of benefit that's hard to see until the coupling that used to prevent it is gone.
Lessons Learned
If I were doing this again, the biggest change wouldn't be the pattern, it would be the sequencing of the hard questions inside it. Rollback state reconciliation should be answered before a module is extracted, not discovered the first time a rollback is actually needed. That's the mistake I've seen repeated across more than one migration like this: the feature flag gets treated as a full safety net, and it's only a partial one.
The other thing that consistently runs long is the estimate for how many modules can be extracted per quarter. Every module surfaces its own quirks, a shared table nobody remembered was shared, a background job that reads directly from the legacy database instead of through an API, and each of those quirks costs time that a clean extraction plan doesn't account for. I've learned to budget for that discovery time explicitly now rather than treating each extraction as a repeat of the last one.
None of that changes the core call. For a system tied to continuous operations, I'd choose the strangler pattern over a rewrite again, and for the same reasons: it trades speed for the ability to stop or reverse course after any single step. What I'd do differently is stop treating each module extraction as a smaller version of the same problem, and start treating "what does rollback actually mean for this specific module's data" as its own design question, every time.
ADR Summary
Context: A legacy healthcare platform, patient scheduling, records, and billing on a single .NET monolith, needed new capability delivered on a normal cadence without downtime, with a small team and compliance requirements around patient data.
Decision: Modernize incrementally using the strangler fig pattern: an API gateway in front of the monolith, extracted services behind feature-flag-controlled routing, lowest-risk modules extracted first.
Status: Adopted.
Consequences: Longer overall timeline and real dual-system operational overhead for the duration of the migration, in exchange for zero-downtime delivery and a rollback path that doesn't require a redeploy. Requires an explicit, per-module answer to what rollback means for that module's data, especially for compliance-sensitive modules like billing, rather than assuming a feature flag is a full undo.
Planning a complex platform decision?
I’m always interested in thoughtful conversations around architecture, cloud strategy, and practical AI-enabled systems.
Start a Conversation