Repay Tech Debt with the Strangler Fig Pattern
Replacing a business-critical legacy system does not have to mean committing to a risky, all-at-once rewrite. The strangler fig pattern offers a practical alternative: migrate one capability at a time, run the old and new systems side by side, and gradually retire the legacy code as each replacement is validated. This post walks through how it works, where it fits, and a practical Rails-based example of migrating one piece of functionality without stopping the business to do it.
The Problem: A Business-Critical System You’re Afraid to Touch
Most organizations don’t wake up one day and decide to replace a legacy system. It happens gradually, as the cost and risk of maintaining the system tends to increase over time .
One common problem is that the application becomes difficult to change. As a result, teams may avoid making necessary improvements because the risk of introducing regressions is too high. Older dependencies may no longer be supported and infrastructure may require specialized operational knowledge. Security vulnerabilities accumulate. Old dependencies stop receiving patches, frameworks fall out of active support, and the gap between “known vulnerable” and “actually fixed” widens.
Despite these problems, replacing the application can be risky. The system may contain years of accumulated business logic, including edge cases that are not documented anywhere else. A complete replacement must account for those behaviors while continuing to support current users, integrations, and operational requirements. This makes a full rewrite difficult to estimate and potentially disruptive.
Doing nothing, however, is not a sustainable strategy. Technical debt continues to accumulate as dependencies age, knowledge is lost, and temporary workarounds become permanent parts of the application. Over time, the organization has fewer safe options and less flexibility in deciding how and when to replace the system.
Why “Just Rewrite It” Doesn’t Work
Faced with a system like this, the instinct is often to start fresh: freeze the old system, build a new one properly, and cut over once it’s ready. In practice, this approach carries its own set of serious risks, which is why full rewrites so often stall, run over budget, or get abandoned partway through.
Instead of a series of small, reversible changes, the business is betting on one large release, often after months (or years) of work, with no real-world validation until the very end. If something is wrong it surfaces all at once, after the cost of building the replacement has already been paid.
It is also rarely practical to freeze feature development while the replacement is being built. The existing application still supports an active business, and users continue to request changes. Regulations, market conditions, internal processes, and customer expectations may also change during the rewrite. As a result, the legacy system continues to evolve while the new system is under development.
If the old system keeps changing while the new one is being built, then the team is effectively maintaining two systems in parallel (one in production, one in development). This work grows more difficult the longer the rewrite takes, and it’s easy for parity to quietly erode without anyone noticing until launch.
The Strangler Fig Pattern
The name comes from a real botanical phenomenon: strangler fig vines take root in the branches of a host tree, gradually growing around it. Over years, the fig develops its own root and trunk system, until eventually it no longer needs the host at all. Applied to software, this idea gives us a middle path between keeping a legacy system indefinitely and replacing it all at once.
The mechanics are straightforward:
- First create a seam, a well-defined point where requests or behavior can be intercepted, such as a router, API gateway, or facade layer.
- Next intercept requests at that seam and decide, for each one, whether it should be handled by the legacy system or the new implementation.
- Then redirect traffic to the new implementation gradually, one slice of functionality at a time, validating each piece before moving to the next.
- Finally decommission the corresponding old code path once a piece has been fully and reliably replaced.
The old and new systems therefore operate together for a period of time. As each replacement is tested and proven, more requests are routed to the new system and the corresponding legacy code path is decommissioned. This cycle continues until the legacy application has few or no remaining responsibilities and can be retired.
How This Helps With Technical Debt
Before we get to the mechanics, it’s worth connecting this pattern to the problem it’s actually solving. The strangler fig pattern allows teams to reduce technical debt without stopping product development. The process can also improve the structure of the application. Creating seams between capabilities encourages clearer architectural boundaries, while migrating individual areas often leads to better automated tests, monitoring, and operational visibility. If you’re also looking to improve the code you’re keeping, our post on refactoring Rails applications covers how we approach that work alongside debt reduction.
However, creating a modern system is not enough on its own. Technical debt is only reduced when the corresponding legacy capability is removed. If the old code path remains active, the organization must maintain both implementations and may end up increasing complexity instead. For that reason, decommissioning should be part of the definition of done. Over time, this turns technical-debt reduction from a one-time project into an ongoing delivery practice: identify a costly area, replace it safely, remove the old implementation, and repeat.
Running the Old and New Systems Together
The strangler fig pattern requires the legacy and modern systems to operate at the same time. A routing mechanism decides which system should handle each request.
The routing mechanism can take a few different forms, depending on where the seam naturally falls in the architecture. This mechanism might be a reverse proxy, an API gateway, an application façade, or routing logic inside the application itself. For asynchronous workflows, a message broker or event stream can serve a similar purpose by directing events to legacy or modern consumers.
The other key decision we need to make is how to divide the system into pieces that can be migrated independently. There are two common approaches. Divide the system by route or endpoint, migrating one API endpoint or URL path at a time, well suited to systems with a clear request/response structure. Alternativly, use modules or domains as boundaries, then migrate one area of business logic at a time (billing, authentication, reporting), better suited to systems where the seams are conceptual rather than purely technical.
Managing Data During the Transition
Data is often the most difficult part of running the legacy and modern systems together. The key question is not only where data is stored, but: which system is the source of truth for it, right now?
A few common approaches:
- The legacy system remains the source of truth, even for capabilities that have already been migrated. The new system reads from or defers to the legacy system’s data, which keeps ownership simple but limits how independently the new system can operate.
- The new system takes ownership of data for capabilities it has already migrated, with the legacy system deferring to it instead. This is usually the direction migrations move toward over time.
- The two systems’ data is kept synchronized, with neither fully deferring to the other. This can work as a transitional state, but it requires clear rules about how conflicts are resolved when both systems can write.
- Changes are propagated via events, so that whichever system makes a change publishes it, and the other system consumes and applies it. This decouples the two systems more cleanly than direct synchronization, at the cost of eventual (rather than immediate) consistency.
Whichever combination of these is used, the risk of leaving ownership ambiguous is the same. The main safeguard is explicit ownership. For each category of data, the team should define which system can write it, how changes are propagated, and how inconsistencies are detected and corrected. Without those restrictions, both systems can begin modifying the same information independently, making errors difficult to trace and resolve.
The data strategy may change as the migration progresses, but ownership should never be ambiguous.
Mitigating Risk Along the Way
The strangler fig pattern reduces risk by design, simply by breaking a migration into smaller steps. But smaller steps need to be paired with safeguards that catch problems early and make it easy to back out when something isn’t working.
Feature flags and incremental traffic routing allow the team to control what traffic to send to the new implementation. A new capability can first be enabled for internal users, a small customer segment, or a limited percentage of requests. If problems appear, traffic can be redirected to the legacy implementation without needing to redeploy or reverse the entire migration.
Automated tests are important, but they are not sufficient on their own. The new system must also be observed in production, at minimum, this means tracking completed transactions, processing times, and mismatched records between the two systems.
Reconciliation processes help detect issues that normal monitoring may miss. For example, a scheduled comparison can verify that both systems processed the same orders, produced the same totals, or reached the same final state.
Every migration step should also have a documented rollback procedure. The team should know how to restore the previous routing, what happens to data created by the new system, and whether any actions need to be reconciled after the rollback.
A Practical Game Plan
A strangler fig migration usually follows a repeatable sequence.
- Identify a seam. Look for a boundary that’s already relatively clean such as a single API endpoint, a self-contained module, or a domain with limited dependencies on the rest of the system.
- Build the interception point. Stand up whatever routing mechanism fits the seam (a proxy rule, a facade method, a conditional branch) so that traffic for this piece can be redirected, without yet redirecting all of it.
- Migrate one capability. The first candidate should be meaningful enough to provide value, but contained enough to limit risk. Moving one well-defined capability also gives the team a chance to test the migration approach before applying it more broadly.
- Validate before fully switching over. Tests confirm the new implementation behaves correctly in isolation. Shadow traffic sends real production requests to the new implementation without using its response, so its behavior can be observed under real conditions before it’s trusted. Finally we compare logging outputs of both systems side by side, looking for differences between the legacy and new implementations.
- Expand. Once the new capability behaves reliably, traffic can be shifted gradually. The same process can then be repeated for the next capability.
- Know what “done” looks like. It is critical that we define what “done” means for both the migration as a whole and each individual slice. We must know what capabilities need to move, what the legacy system’s retirement looks like, and how success will be measured along the way.
When It Goes Wrong
The strangler fig pattern is straightforward to describe, but easy to execute poorly. Most failures come from losing sight of the discipline the pattern requires, rather than from the pattern itself being wrong for the job. One common mistake is building the new system without retiring the old one, but there are other important missteps worth being aware of.
One such misstep is choosing the wrong migration boundaries. Moving technical components, such as a database table or utility module, may not remove a complete responsibility from the legacy system. Migrating an end-to-end business capability usually creates a clearer ownership boundary and makes decommissioning more practical.
A newer technology stack does not automatically produce a better system. Teams can reproduce the same coupling and unclear boundaries if they copy the legacy architecture too closely. At the same time, they may underestimate undocumented behavior and discover late in the process that important edge cases were not included in the replacement.
Trying to migrate too many areas at once can make these problems harder to control. Each stream introduces its own routing, data, testing, and operational concerns. A smaller number of focused migrations makes it easier to learn from each step and apply those lessons to the next one.
Modernization also needs business ownership. If it is treated as a side project, feature work will usually take priority and the legacy system will remain in place. The migration is more likely to succeed when each step has a business reason, a responsible owner, and a specific decommissioning outcome.
A Practical Example
To make this concrete, consider a hypothetical order-management application, imagine a Rails monolith that’s been in production for years. It handles order creation, payment processing, shipment tracking, customer notifications, and reporting, all in one codebase.
The team decides to migrate customer notifications first. Notifications have a clear boundary, depend on a limited set of order data, and can be validated without impacting the critical path for order or payment processing.
Initially, the legacy application sends notifications directly after an order changes state:
# app/models/order.rb
class Order < ApplicationRecord
after_update :send_status_notification, if: :saved_change_to_status?
private
def send_status_notification
LegacyOrderMailer.status_changed(self).deliver_later
end
end
Step 1: The legacy system publishes an event.
The first step is to introduce an event at the point where the order status changes. The specific mechanism depends on what’s already in the stack, this could be ActiveSupport::EventReporter , ActiveSupport::Notifications, a Sidekiq job if the codebase already leans on background jobs, or a message broker like Kafka or SQS if the team wants stronger decoupling from the start. The example below uses ActiveSupport::Notifications, since it requires no new infrastructure and is available in any Rails app.
The legacy application is changed to publish an event describing the change:
# app/models/order.rb
class Order < ApplicationRecord
after_update :send_status_notification, if: :saved_change_to_status?
after_update :publish_status_event, if: :saved_change_to_status?
private
def send_status_notification
LegacyOrderMailer.status_changed(self).deliver_later
end
def publish_status_event
ActiveSupport::Notifications.instrument(
"order.status_changed",
order_id: id,
status: status,
customer_id: customer_id,
occurred_at: Time.current
)
end
end
This is the seam: a single, well-defined point where order status changes become observable to other systems, without yet changing who acts on them.
Step 2: The new notification service consumes the event.
For this example migration, the new notification logic lives in an isolated module within the same repository (app/services/notifications_v2/). A subscriber listens for order.status_changed and hands the payload off to a builder, which is defined in Step 3:
# app/services/notifications_v2/order_status_subscriber.rb
module NotificationsV2
class OrderStatusSubscriber
ActiveSupport::Notifications.subscribe("order.status_changed") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
# MessageBuilder#build_and_log is shown in Step 3.
# Here we construct and log a message, it does not send anything.
NotificationsV2::MessageBuilder.new(event.payload).build_and_log
end
end
end
At this point, nothing customer-facing has changed. The new service is simply listening and building notifications for its own internal record.
Step 3: Notifications are generated by both systems, but sent only by the legacy system.
The legacy application continues to send every notification, exactly as before. The new service does not send anything, instead it generates what it would send, but its output is logged rather than delivered:
# app/services/notifications_v2/message_builder.rb
module NotificationsV2
class MessageBuilder
def initialize(payload)
@payload = payload
end
def build_and_log
message = build_message
ComparisonLog.record(
order_id: @payload[:order_id],
source: "notifications_v2",
message: message
)
message
end
private
def build_message
# message construction logic
end
end
end
For this comparison to mean anything, the legacy mailer also needs to log what it actually sends. A small change records that:
# app/mailers/order_mailer.rb
class LegacyOrderMailer < ApplicationMailer
def status_notification(order)
LegacyNotificationLog.record(
order_id: order.id,
message: notification_body(order)
)
mail(to: order.customer.email, subject: "Order update")
end
end
Step 4: Outputs are compared.
A comparison job checks the legacy system’s sent notifications against the new service’s logged output for the same events, flagging any mismatches in content, timing, or recipients:
# app/jobs/notification_comparison_job.rb
class NotificationComparisonJob < ApplicationJob
def perform(order_id)
legacy = LegacyNotificationLog.for_order(order_id)
new_service = ComparisonLog.for_order(order_id)
unless legacy.message == new_service.message
MismatchReporter.report(order_id:, legacy:, new_service:)
end
end
end
This shadow period gives the team production evidence without changing customer-facing behavior. Differences can be reviewed to determine whether there are defects in the new service or undocumented behavior in the legacy application.
Step 5: The new service starts sending notifications.
Once mismatches have dropped to zero (or an accepted, understood baseline), a feature flag controls which implementation sends the notification. Before the flag is enabled, the new service is updated to actually deliver notifications. The MessageBuilder gains a build_and_send path alongside the existing build_and_log, using the same message construction logic already validated during the shadow period. The subscriber checks the feature flag to decide which path to call.
# app/models/order.rb
class Order < ApplicationRecord
after_update :send_status_notification, if: :saved_change_to_status?
after_update :publish_status_event, if: :saved_change_to_status?
private
def send_status_notification
# Legacy mailer runs when the flag is off; new system takes over when enabled
LegacyOrderMailer.status_notification(self).deliver_later unless Feature.enabled?(:modern_order_notifications, customer)
end
def publish_status_event
# Always fires; the subscriber decides whether to act or just log
ActiveSupport::Notifications.instrument(
"order.status_changed",
order_id: id,
status: status,
customer_id: customer_id,
occurred_at: Time.current
)
end
end
The flag can first be enabled for internal accounts, then for a small percentage of customers, and eventually for all traffic. During the rollout, the team monitors delivery failures, processing times, duplicate notifications, and differences in message content.
Step 6: Notification code is removed from the legacy application.
The legacy notification-sending code is deleted, not just disabled. This includes everything from templates, delivery logic and related configuration. This is the step that actually pays down technical debt, everything before it was preparation.
# app/models/order.rb
class Order < ApplicationRecord
after_update :publish_status_event, if: :saved_change_to_status?
private
def publish_status_event
ActiveSupport::Notifications.instrument(
"order.status_changed",
order_id: id,
status: status,
customer_id: customer_id,
occurred_at: Time.current
)
end
end
Step 7: The team moves on to shipment tracking.
With one capability fully migrated and one seam retired, the same process (publish an event, build the new implementation, compare outputs, cut over, remove the old code) is applied to the next capability. Order creation, payment processing, and reporting remain untouched in the legacy system for now, each waiting its turn.
Nothing in this example required stopping the order-management system, freezing feature work, or making a single high-stakes cutover. The legacy and new systems coexisted for exactly as long as it took to validate one piece of functionality.
Conclusion
Replacing a legacy system does not require a single high-risk launch. As this post has covered, the strangler fig pattern offers a way to modernize gradually. It allows us to validate each replacement in production before committing to it, while keeping the business operating throughout the transition.
It is particularly useful when the legacy system is large enough that a full rewrite is not practical, must remain available, and cannot be replaced without interrupting ongoing feature development. It also works best when the application contains distinct capabilities that can be separated and migrated independently.
The pattern may be unnecessary for a small system that can be replaced safely in a short period. For a large, business-critical application, however, spreading risk across a series of controlled releases is often more practical than concentrating it in one cutover.
Technical debt reduction, approached this way, stops being a single high-stakes project and becomes something closer to routine engineering practice. Done well, modernization becomes a sequence of measurable and reversible improvements. Each step reduces the scope of the legacy system, lowers a specific source of technical debt, and creates a safer foundation for the next migration.
Is your Rails application carrying more legacy code than it should? We can help .