Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 064 / 100

The strangler fig pattern in Design Patterns in Practice

Replace a legacy system piece by piece.

Expona Admin8 min read

The strangler fig pattern

I've watched teams attempt a complete rewrite of a legacy system and watched them fail spectacularly. The strangler fig pattern is a safer way: you replace the old system piece by piece without ever pulling the plug on the whole thing at once. It's named after the fig tree, which wraps around another tree and gradually takes over until the original is gone, all while the combined system keeps growing.

If you're managing a codebase that's been running for five or ten years, you understand the risk. A big-bang rewrite is dangerous because you lose all the implicit knowledge baked into the old system—edge cases, regulatory quirks, performance tricks—and you burn runway doing it. The strangler fig pattern lets you migrate incrementally, ship value early, and roll back if needed.

Migration flow: proxy, reroute, repeat, retire

The pattern unfolds in four concrete steps, and I've used this exact sequence on a legacy CRM system we rewrote in NestJS while keeping the old .NET application alive.

Step 1: Put a proxy in front of the legacy app. Before you change anything, insert a routing layer between clients and the legacy system. This proxy intercepts all incoming requests. At the start, every request still goes to the legacy app—nothing changes operationally. But now you have a choke point where you can decide which requests go where.

In practice, this might be a reverse proxy (nginx, HAProxy), an API gateway (AWS API Gateway, Kong), or even a lightweight Node.js/NestJS adapter. The critical thing is that you own the routing logic.

Step 2: Route one feature to the new system. Once your proxy is live and stable, you identify the first feature to migrate. Maybe it's user authentication, or order history retrieval, or report generation—something with clear boundaries. You build that feature in your new stack, connect it to shared data sources, and tell the proxy: "For requests matching this pattern, go to the new system instead."

Step 3: Repeat feature by feature. After the first feature runs cleanly for a few weeks, you migrate the next one. Then the next. Each iteration is small, testable, and reversible. You're not rewriting everything at once; you're swapping out one subsystem while the old code still handles everything else.

Step 4: Retire the legacy app. Eventually, every feature has moved to the new system, and no requests hit the old code anymore. At that point, the legacy app is just sitting idle. You can shut it down, archive it, or keep a snapshot for compliance.

Low risk: small steps and reversibility

The safety of this pattern comes from its granularity. If something goes wrong with one migrated feature, you're not rolling back a three-month rewrite—you're rolling back a two-week feature migration.

On the CRM project, we migrated contact search first. The new system had a bug where it returned results in a different sort order than the legacy system, and some customers relied on the old behavior. Because only contact search was routed to the new system, we flipped it back to the old code in minutes. No incident. No lost data. We fixed the sort order, retested, and rerouted again a few days later.

Contrast that with a big-bang rewrite: the sort bug would have affected every feature, every user, and you'd be scrambling at 2 a.m. trying to patch production while everything else was broken.

Canary deployments work naturally here too. You don't have to migrate all requests for a feature at once. Route 5% of traffic to the new system, watch the metrics, and ramp it up. If error rates spike, kill the routing rule and drop it back to zero. This gives you production validation before you commit fully.

I've found that feature flags inside the migrated code are equally important. Even if the proxy routes a request to the new system, a feature flag can say "don't actually use the new logic yet—call the legacy system for data and just proxy the response." This lets you test the integration without actually flipping the switch.

Value early: new features ship immediately

One huge advantage of the strangler fig pattern is that you don't have to wait for the full migration before building new features. Once the proxy is in place, new work can go straight into the new system.

In that CRM rewrite, we finished the proxy infrastructure in week two. By week four, product started asking for features like advanced search filters and bulk operations. We built those in NestJS first, even though 80% of the old functionality was still running on the legacy .NET app. No one cared—they got the features they wanted, and the proxy made it seamless for them.

This also means your team gets to work in the new codebase immediately and builds confidence in it. You're not waiting for a three-year project to finish before you see any value. The proxy becomes a living integration point that proves the new system works.

Another hidden benefit: you can prioritize features by migration difficulty, not by what the old system does best. Maybe the legacy app has a really slow reporting engine. Migrate that first, because the new system will be obviously faster, and you'll have an early win. Features that are working fine in the old system can move later.

Watch shared data: synchronization is usually hardest

The thorniest part of the strangler fig pattern is almost always data. When the old system and new system are both running, they need to see a consistent view of the database, and that's harder than it sounds.

Let me walk through a realistic scenario. You're migrating user profile management to NestJS while the old .NET app still handles everything else. A user updates their profile in the new system, and that change lands in the database. But then the legacy app reads that user record and doesn't know how to interpret a new field you added for the migration. Or the user updates their profile in the legacy app, and the new system's cache is now stale.

Option 1: Shared database. Both systems read and write to the same PostgreSQL database. This is simplest initially but creates tight coupling. If the new system's migration adds a column, the old system ignores it (usually fine), but if the old system deletes data the new system depends on, you have a problem. Also, neither system owns the schema, which gets messy fast.

Option 2: Dual writes. When a request to the new system modifies data, you write to both the new database and the legacy database. This keeps them in sync at the cost of distributed transaction complexity. If the write to one database fails, you have inconsistency. I've seen this pattern cause data corruption on a trading platform because a network glitch caused dual writes to partially fail.

Option 3: Event-driven sync. When the legacy system modifies data, it publishes an event (to Kafka, RabbitMQ, or similar). The new system subscribes and syncs its own copy. Vice versa for new-system changes. This is my preferred approach because it's explicit, auditable, and resilient. But it requires both systems to speak events, and you have to handle eventual consistency (data takes a few milliseconds to sync).

On that CRM project, we went with option 3. When a contact was updated in the legacy system, a Kafka producer published a ContactUpdated event. The new NestJS service consumed it and updated its PostgreSQL database. When a user updated a contact in the new system, we published an event and the legacy system listened. It meant there was a brief window where the two databases disagreed, but we handled that with proper locking and conflict resolution.

typescript
// In the legacy .NET app, publish an event when data changes
public async Task UpdateContact(int contactId, UpdateContactDto dto)
{
    var contact = await _db.Contacts.FindAsync(contactId);
    contact.Name = dto.Name;
    contact.Email = dto.Email;
    await _db.SaveChangesAsync();
    
    // Publish to Kafka
    await _kafkaProducer.ProduceAsync("contact-events", new
    {
        EventType = "ContactUpdated",
        ContactId = contactId,
        Name = contact.Name,
        Email = contact.Email,
        UpdatedAt = DateTime.UtcNow
    });
}
typescript
// In the new NestJS service, consume the event
@Injectable()
export class ContactSyncService implements OnModuleInit {
    constructor(
        private kafka: ClientKafka,
        private contactService: ContactService
    ) {}

    onModuleInit() {
        this.kafka.subscribeToResponseOf('contact-events');
    }

    @MessagePattern('contact-events')
    async handleContactUpdated(message: any) {
        if (message.EventType === 'ContactUpdated') {
            await this.contactService.syncFromLegacy(message);
        }
    }
}

Always think about eventual consistency. There will be a moment where the two systems disagree. A user creates a contact in the new system at 10:00:01, and it doesn't appear in the legacy system until 10:00:02 because of async replication. If a customer checks the legacy system in that one-second window, they'll see nothing. Design your UI and your API to accept this. Maybe show a "syncing..." indicator, or read from the new system if the legacy system doesn't have the data yet.

Common mistakes

Trying to migrate everything at once. I've seen teams say "We'll migrate the proxy and three features in month one." They get bogged down in data sync issues and spend the whole month firefighting. Start with one small feature, get it bulletproof, then plan the next. Momentum matters, and early wins build confidence.

Ignoring the legacy system's implicit contracts. That old system might silently accept a request with bad data and return a default value. The new system might throw an error. Users or downstream systems might depend on the old behavior. Before you migrate a feature, spend time understanding what the legacy system actually does, not just what the documentation says it does.

Not monitoring the proxy layer. Your proxy is now a critical system. If it goes down, both the old and new system are unreachable. Monitor latency, error rates, and routing distribution. Watch for requests that are stuck in a routing loop or timing out.

Forgetting to migrate tests and monitoring. You move the code to the new system but forget that no one is monitoring it or testing it yet. The new feature runs fine for two weeks, then fails in production because you never set up alerting. Migrate the tests, the alerting, the dashboards—everything—alongside the code.

Key takeaways

  • The strangler fig pattern wraps a new system around a legacy one, replacing functionality incrementally until the old system is no longer needed.
  • Insert a proxy in front of the legacy system, then gradually reroute requests to new code, feature by feature.
  • Each migration is small and reversible, so a bug in the new system doesn't break everything—just roll back that one feature.
  • New features can ship in the new stack immediately, even while the old system is still running most of the load.
  • Data synchronization is the hardest part; favor event-driven sync over dual writes or shared databases to avoid corruption and tight coupling.
  • Plan for eventual consistency—the two systems will briefly disagree, and that's okay if your UI handles it gracefully.
  • Start small, verify each feature thoroughly before moving to the next, and monitor the proxy layer as if your business depends on it (it does).
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles