Back to Blog
DevelopmentAugust 11, 202611 min read

Strangler Fig: Migrating a System Without Shutting It Down

The strangler fig grows around its host tree until it replaces it completely. The same trick works for migrating a legacy system: feature by feature, with no big bang and nothing switched off.

IM
Ignacio MelendezFull-Stack & Game Developer
Strangler Fig: Migrating a System Without Shutting It Down

In tropical forests there is a tree that does not start from the ground. The strangler fig seed germinates on a high branch of another tree, and from there it drops roots downward. For years the host keeps living normally: it gives shade, it bears fruit, it holds the weight of its tenant. The roots come down, touch each other, fuse together, and form a mesh that wraps the original trunk. One day the host dies and rots away, and what remains standing is a hollow fig with the exact shape of the tree it replaced.

And this is one of the best metaphors for migrating a legacy system. You don't throw it away and build a new one. You grow around it, take responsibilities away from it one by one, and once it is no longer holding anything up, you cut it down.

1. The big bang rewrite and why it almost always fails

The obvious alternative is the full rewrite: one team builds the new system in parallel while another maintains the old one, and when the new one is ready you flip the switch overnight. It is the option that sounds best in a meeting and the one that ages worst.

The first problem is that the old system doesn't sit still. While it is being rewritten, the business keeps asking for features, and those features get implemented in the legacy system because that is what is in production. The target moves. Every month that passes, the new system has one more month of debt to catch up on, and it is not unusual for the rewrite to end up chasing a target that never stops.

The second problem is that no value is delivered until the very end. For twelve or eighteen months the project consumes budget and returns nothing measurable. It is exactly the kind of initiative that gets cancelled when priorities change, and when it gets cancelled, all the work is thrown away.

The third is concentrated risk. On cutover day, everything that can fail fails at once, and the rollback plan is "we go back to the old system", which has spent months not receiving the new data. The rollback window is short and the stress is maximum.

The classic symptom of a big bang going wrong is the phrase "once we finish the migration". If that phrase has been in your meetings for more than two quarters, the new system is no longer a replacement; it is a second legacy system that also isn't in production.

2. The strangler fig: the full metaphor

Martin Fowler coined the name in 2004 after seeing these trees in Australia, and the metaphor holds up quite a bit better than metaphors usually do.

What matters is not that the new tree kills the old one. It is how it does it. The fig does not compete for the same space from the start: it begins at the top, at one very specific point, and from there it extends roots toward areas where the host is still in charge. Each root is a migrated feature. As it descends, the host keeps doing its job; nobody in the forest notices there is a transition under way.

Translated into software, this turns into three rules:

  • The old system stays in production the whole time. There is no shutdown period, no weekend maintenance window.
  • Each feature is migrated independently, with its own deploy, its own validation, and its own rollback.
  • Something decides, per request, who answers. That piece is what makes everything else possible.

The practical difference with the big bang is the size of the risk per event. In a rewrite there is a single enormous critical moment. In a strangler fig there are thirty small critical moments, each one reversible in minutes.

Migrating a system is not a jump. It is a progressive substitution in which, at every instant, something is working.

3. The key piece: the interceptor

The whole pattern depends on a layer that sits in front of both systems and decides where each request goes. It gets called a facade, a proxy, a router, or a gateway depending on the context, but the responsibility is always the same: clients talk to the interceptor and they don't know (and shouldn't know) who is answering them.

That piece can live in several places. In infrastructure, as rules in an nginx or in an API gateway. In the application, as a middleware. In the code itself, as an interface with two implementations. The choice depends on where the cleanest boundary is, but the effect is identical.

1import express from "express"
2import { createProxyMiddleware } from "http-proxy-middleware"
3
4const app = express()
5
6// Routes already migrated to the new system. Everything else stays on the legacy one.
7const MIGRATED_ROUTES = ["/api/users", "/api/invoices"]
8
9app.use((req, res, next) => {
10  const isMigrated = MIGRATED_ROUTES.some((route) => req.path.startsWith(route))
11  const target = isMigrated ? "http://service-new:8080" : "http://legacy-monolith:8080"
12
13  return createProxyMiddleware({ target, changeOrigin: true })(req, res, next)
14})
15
16app.listen(3000)

The route list turns a migration into a configuration. Migrating /api/payments stops being a project and becomes adding one line, with the rollback being removing it.

Install the interceptor before migrating anything, with the migrated route list empty. That way the first deploy only verifies that the proxy works and that it doesn't break anything, without mixing it up with the first feature. If something goes wrong, you know exactly what it is.

4. Migrating the first feature

Choosing the first feature matters more than the implementation. The temptation is to start with the most important one, because that is where the pain is, and it is usually a bad idea: it is the most coupled part and the one that tolerates failure worst.

A good first candidate meets three criteria: it has a clear boundary (little dependency on data that other parts touch), it is easy to verify (you can compare old and new output unambiguously), and its failure is tolerable for five minutes. A read endpoint is usually perfect. A list, a detail view, a report.

That first cut is where you will discover everything the old system did that was never documented: the odd header a 2019 mobile client needs, the field that always comes back as null but that someone parses, the implicit ordering nobody wrote down but that a screen depends on. That is why it pays to start with something small: not because the migration is easy, but because the learning is expensive and you want to pay for it cheaply.

A technique that helps a lot here is running both systems in parallel and comparing them, without the new one answering yet:

1async function handleWithComparison(req: Request): Promise<Response> {
2  const legacyResponse = await legacyService.handle(req)
3
4  // The new one runs in shadow mode: if it fails or differs, it gets logged but has no effect.
5  void newService
6    .handle(req)
7    .then((newResponse) => {
8      if (!deepEqual(legacyResponse.body, newResponse.body)) {
9        logger.warn("strangler.mismatch", {
10          path: req.path,
11          legacy: legacyResponse.body,
12          next: newResponse.body,
13        })
14      }
15    })
16    .catch((error) => logger.error("strangler.shadow_failed", { error }))
17
18  return legacyResponse
19}

A week of real traffic in shadow mode tells you more about the correctness of your implementation than any test suite, because real traffic contains the cases nobody imagined. Once the mismatch counter has been at zero for days, the migration is a configuration change with very little mystery to it.

5. Gradual rollout and rollback

A list of migrated routes is binary: either all the traffic for /api/users goes to the new system, or none of it does. For a small feature that may be enough, but as soon as volume matters you want to be able to open the tap slowly.

This is where the strangler fig leans naturally on feature flags. Instead of a route list, the decision becomes a flag lookup with context: traffic percentage, user identity, region, client type.

1async function resolveTarget(req: Request): Promise<Backend> {
2  const useNewService = await flags.isEnabled("users-service-migration", {
3    userId: req.user?.id,
4    country: req.headers["x-country"],
5  })
6
7  return useNewService ? newBackend : legacyBackend
8}

With this, the typical rollout is: internal team, then 1%, then 10%, then 50%, then everyone. Between each step you look at the same metrics you would look at in any deploy (error rate, p99 latency, and the business metrics that feature moves), and if something goes sideways the flag goes back to zero in seconds with no deploy needed.

That is the property that makes the pattern sustainable: the rollback is not a contingency plan nobody has ever tested, it is an operation that gets executed several times a week.

Migration flags are short-lived flags by definition. When a feature has been at 100% on the new system for a month, that flag is debt: delete the legacy branch and delete the flag. A strangler fig that doesn't clean up its flags ends up with a router nobody understands.

6. Dual writes and data consistency

Everything above is comfortable as long as the migrated feature only reads. As soon as it writes, the real problem shows up: two systems, two data models, and one reality that has to stay single.

There are three strategies, in increasing order of pain.

The simplest is sharing the database. The new system reads and writes to the same tables as the legacy one. No inconsistency is possible because there is no duplication. You pay for it with coupling: the new system inherits the old schema, with its weird names and its dead columns. For migrations lasting months it is almost always the right option, and it is the one fewest people pick because it doesn't feel "clean".

The second is the dual write: every operation gets written to both systems. It sounds reasonable and it is the one that generates the most silent problems, because the write is not atomic. If the first one succeeds and the second one fails, they end up divergent and nobody finds out until a report doesn't add up.

1async function updateUser(user: User): Promise<void> {
2  await legacyRepository.save(user)
3
4  try {
5    await newRepository.save(user)
6  } catch (error) {
7    // The legacy system is the source of truth: the failure is not propagated to the client,
8    // but it gets queued for reconciliation. Without this, the divergence is invisible.
9    await reconciliationQueue.push({ entity: "user", id: user.id })
10    logger.error("strangler.dual_write_failed", { id: user.id, error })
11  }
12}

The rule that makes dual writes viable is declaring a single source of truth at any given moment. At the start it is the legacy system and the new one is a copy; after the cutover it flips. What can never happen is both being authoritative at the same time, because then there is no way to resolve a conflict other than by hand.

The third strategy is event-based synchronization: the legacy system publishes changes (via CDC on the database log, or by emitting domain events) and the new system consumes them to build its own model. It is the most flexible one and the one that lets the new system have a genuinely independent schema, but it introduces eventual consistency and all the infrastructure that drags along with it. It is worth it when the migration is going to last more than a year, or when the new model differs a lot from the old one.

A dual write without a reconciliation process is not a dual write, it is duplication with hope. Before turning it on, it is worth writing the job that compares both sides and reports the differences. And running it from day one, not when there are suspicions that something is off.

7. The part nobody does: cutting down the host

The fig finishes the job. The host tree dies, rots, and leaves a hollow. In software, that last phase is the one 90% of migrations skip, and it is the one that decides whether the pattern was a success or whether you just added one more system.

The terminal state of a half-finished migration is worse than never having started: there are two live systems, two deploys, two on-call rotations, a router in the middle with rules nobody remembers the reason for, and features where it is anyone's guess which of the two serves them. The complexity hasn't moved, it has doubled.

Avoiding that is more organizational than technical. Three things help:

  • Define the ending from the start. Not "migrate to microservices", but "the billing monolith receives no traffic and its repository is archived". A criterion you can verify by looking at a dashboard.
  • Measure progress by traffic, not by features. The percentage of requests still reaching the legacy system is the only honest number. You may have migrated twenty endpoints and still have 70% of the traffic on the old one.
  • Delete when it is time, not at the end. Every time a feature has been stable on the new system for a month, its code gets deleted from the legacy one in the same sprint. If it piles up for "one big cleanup at the end", that cleanup never happens.

When the legacy traffic counter hits zero and stays there for a few weeks, the most satisfying step is left: shut the service down, wait for nobody to scream, and delete the repository. The interceptor can be simplified then too, because it no longer decides anything.

8. When not to use it

The pattern has a real cost and it doesn't always pay off. Throughout the migration you maintain two systems, a routing layer, and probably a data synchronization mechanism. That is continuous work.

If the system is small (a few weeks of rewriting), the big bang is cheaper and simpler. If the system is frozen and has no active users, there is nothing to strangle. And if the legacy system can't be put behind an interceptor, because clients talk to it directly and you can't change them, the pattern doesn't apply until you solve that first.

Where it shines is in the most common case: a large system, in production, with real traffic, that keeps evolving and that nobody can afford to switch off. There, the fig is the only strategy that delivers value from the first month and that lets you stop at any point without having broken anything.

What makes it work is not any specific piece of infrastructure. It is accepting that the migration is not an event, but a way of working for a long stretch of time, and that the metric for success is not the completion date, but that every week there is a little less old trunk holding weight.

Related Articles

View all articles
Feature Flags Without Going Crazy

Feature Flags Without Going Crazy

Feature flags are a simple idea that turns into silent debt. This article covers the full cycle: adding a flag with context, gradual rollout, and the part nobody documents: cleanup.

Repository Pattern: Your UI Shouldn't Know Where Data Comes From

Repository Pattern: Your UI Shouldn't Know Where Data Comes From

An interface that hides whether data comes from the network, cache, or disk. How to swap the source without touching a single screen.