# Strangler Fig Pattern for Modular Monolith Migration

> Big-bang rewrites fail for predictable reasons: they take longer than planned, the legacy system keeps changing underneath them, and the business sees nothing until the end. The Strangler Fig pattern replaces a legacy monolith one bounded context at a time, with feature-flag routing, data sync, and a parallel run before every cutover. The system stays fully functional throughout.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/strangler-fig-modular-monolith-migration

The strangler fig pattern replaces a legacy system incrementally: you build new modules alongside the old code, route traffic to them one bounded context at a time, and remove the legacy code once each piece is proven.
The system stays fully functional throughout, so you never pause feature work for a rewrite.
Here is the playbook, from the routing seam to the final cutover.

Legacy monolith to modular monolith is the migration most .NET teams actually need, far more often than monolith to microservices.

## The Big Rewrite Trap

You have a legacy monolith - spaghetti code, shared database, tangled dependencies. The temptation is to rewrite everything from scratch.

This almost never works. Big rewrites take longer than expected, introduce new bugs, and often get abandoned.

The [Strangler Fig pattern](https://martinfowler.com/bliki/StranglerFigApplication.html) offers a better approach. Just like the strangler fig tree gradually envelops its host tree, you gradually replace the legacy system with a [**Modular Monolith**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet) - one module at a time.

The same technique also works for the next step, [**migrating a modular monolith to microservices**](https://milanjovanovic.tech/blog/breaking-it-down-how-to-migrate-your-modular-monolith-to-microservices). Here I'll focus on the first move: legacy monolith to modular monolith.

## How the Strangler Fig Pattern Works

The strategy is simple:

1. **Identify a bounded context** in the legacy system
2. **Build a new module** that handles the same functionality
3. **Route traffic** to the new module instead of the legacy code
4. **Remove the old code** once migration is complete
5. **Repeat** for the next bounded context

At any point, the system is fully functional. Some requests go to the new modules, others still hit the legacy code.

![Cyclical flow of the strangler fig migration: identify a bounded context, build a new module, route traffic via a feature flag, run in parallel to verify, cut over and remove the legacy code, then repeat for the next context](https://milanjovanovic.tech/blogs/articles/strangler-fig-modular-monolith-migration/strangler-fig-cycle.png)

## Step 1: Add a Routing Layer

You need a seam where each request decides whether the legacy code or the new module handles it.

The cleanest seam is an interface with two implementations, switched by a **feature flag**:

```csharp
public class OrderServiceRouter : IOrderService
{
    private readonly LegacyOrderService _legacy;
    private readonly NewOrderModule _newModule;
    private readonly IFeatureManager _features;

    public OrderServiceRouter(
        LegacyOrderService legacy,
        NewOrderModule newModule,
        IFeatureManager features)
    {
        _legacy = legacy;
        _newModule = newModule;
        _features = features;
    }

    public async Task<OrderDto> GetOrderAsync(Guid id)
    {
        if (await _features.IsEnabledAsync("NewOrdersModule"))
        {
            return await _newModule.GetOrderAsync(id);
        }

        return await _legacy.GetOrderAsync(id);
    }
}
```

Register both implementations and the router in `Program.cs`:

```csharp
builder.Services.AddFeatureManagement();

builder.Services.AddScoped<LegacyOrderService>();
builder.Services.AddScoped<NewOrderModule>();
builder.Services.AddScoped<IOrderService, OrderServiceRouter>();
```

The flag check happens at call time, so flipping the flag takes effect immediately - no redeploy needed.
And because the same check routes in both directions, rollback is the same flag flipped back.

If the legacy system is a separate application (not code you can share a process with), the routing layer becomes a reverse proxy like YARP: migrated routes go to the new application, everything else is forwarded to the legacy one. The principle is identical; only the seam moves from an interface to an HTTP route table.

## Step 2: Build the First Module

Choose the least coupled, highest-value bounded context. Common good candidates:
- **Notifications** - clear boundary, few dependencies
- **Payments** - well-defined interface, critical for business
- **User profiles** - self-contained data

Build the module with proper [**module boundaries**](https://milanjovanovic.tech/blog/module-boundaries-bounded-contexts):

```
Modules/
  Orders/
    Orders.Application/
    Orders.Domain/
    Orders.Infrastructure/
    Orders.Contracts/
LegacyCode/
  OrdersLegacy/         ← still running
  CustomersLegacy/      ← still running
  ShippingLegacy/       ← still running
```

The new module has its own:
- Database schema (or separate tables)
- Domain model
- Public API (contracts)

## Step 3: Data Migration

The hardest part. You need to migrate data from the legacy schema to the new module's schema.

### Option A: Shared Database, New Schema

```sql
-- Legacy table (default schema)
SELECT * FROM public.orders;

-- New module table (module schema)
SELECT * FROM orders.orders;
```

Both schemas coexist in the same database. The new module reads from `orders.orders` - its own schema. During migration, you sync data between schemas:

```csharp
public class OrderDataMigrationJob : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private DateTime _lastSync = DateTime.MinValue;

    public OrderDataMigrationJob(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var legacyDb = scope.ServiceProvider
                .GetRequiredService<LegacyDbContext>();
            var newDb = scope.ServiceProvider
                .GetRequiredService<OrderDbContext>();

            DateTime syncStartedAt = DateTime.UtcNow;

            // Sync legacy orders modified since the last run
            var legacyOrders = await legacyDb.Orders
                .Where(o => o.ModifiedAt > _lastSync)
                .ToListAsync(ct);

            foreach (var legacyOrder in legacyOrders)
            {
                var existing = await newDb.Orders
                    .FirstOrDefaultAsync(o => o.Id == legacyOrder.Id, ct);

                if (existing is null)
                {
                    newDb.Orders.Add(MapToNewModel(legacyOrder));
                }
                else
                {
                    existing.UpdateFrom(legacyOrder);
                }
            }

            await newDb.SaveChangesAsync(ct);
            _lastSync = syncStartedAt;

            await Task.Delay(TimeSpan.FromSeconds(30), ct);
        }
    }
}
```

A few details that matter here.
`BackgroundService` is a singleton, so the job resolves the scoped `DbContext` instances through `IServiceScopeFactory` on every iteration (injecting them directly into the constructor throws at startup).
The job records `syncStartedAt` before querying, so rows modified mid-sync are picked up on the next pass instead of being skipped.
`MapToNewModel` and `UpdateFrom` are plain mapping helpers that translate legacy columns into the new domain model.
Register the job with `builder.Services.AddHostedService<OrderDataMigrationJob>();`.

### Option B: Event-Based Sync

Use [**domain events**](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems) or change data capture to keep both systems in sync.
Since the legacy code and the new module share a process during the migration, MediatR notifications work well as the sync channel:

```csharp
// The sync event
public sealed record OrderCreatedEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal Total,
    DateTime CreatedAt) : INotification;

// The legacy code calls this hook after every write
public class LegacyOrderEventPublisher
{
    private readonly IPublisher _publisher;

    public LegacyOrderEventPublisher(IPublisher publisher)
    {
        _publisher = publisher;
    }

    public async Task OnOrderCreatedAsync(LegacyOrder order, CancellationToken ct)
    {
        await _publisher.Publish(
            new OrderCreatedEvent(
                order.Id,
                order.CustomerId,
                order.Total,
                order.CreatedAt),
            ct);
    }
}

// New module subscribes
public class OrderCreatedEventHandler
    : INotificationHandler<OrderCreatedEvent>
{
    private readonly OrderDbContext _db;

    public OrderCreatedEventHandler(OrderDbContext db)
    {
        _db = db;
    }

    public async Task Handle(
        OrderCreatedEvent notification, CancellationToken ct)
    {
        var order = Order.Create(
            notification.OrderId,
            notification.CustomerId,
            notification.Total);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
    }
}
```

## Step 4: Parallel Running

Run both implementations simultaneously to verify the new module produces correct results:

```csharp
public class ParallelVerificationService : IOrderService
{
    private readonly LegacyOrderService _legacy;
    private readonly NewOrderModule _newModule;
    private readonly ILogger<ParallelVerificationService> _logger;

    public ParallelVerificationService(
        LegacyOrderService legacy,
        NewOrderModule newModule,
        ILogger<ParallelVerificationService> logger)
    {
        _legacy = legacy;
        _newModule = newModule;
        _logger = logger;
    }

    public async Task<OrderDto> GetOrderAsync(Guid id)
    {
        var legacyResult = await _legacy.GetOrderAsync(id);
        var newResult = await _newModule.GetOrderAsync(id);

        // OrderDto is a record, so this compares values
        if (!legacyResult.Equals(newResult))
        {
            _logger.LogWarning(
                "Mismatch for order {OrderId}. Legacy: {@Legacy}, New: {@New}",
                id, legacyResult, newResult);
        }

        // Return the legacy result until verified
        return legacyResult;
    }
}
```

It implements the same `IOrderService` seam, so during the parallel-run phase you register it in place of the router.
Compare on read paths like this one; doubling up writes would create duplicate side effects.

Once you're confident the new module is correct, flip the feature flag.

## Step 5: Cut Over and Clean Up

After the new module is proven:

1. Point all traffic to the new module
2. Keep the legacy code around for a rollback period
3. Stop the data sync
4. Delete the legacy code
5. Drop the legacy database tables

```json
{
  "FeatureManagement": {
    "NewOrdersModule": true,
    "NewPaymentsModule": true,
    "NewShippingModule": false
  }
}
```

Here, Orders and Payments are fully migrated, and Shipping is next up.

## Migration Timeline

A realistic per-module timeline looks like this:

- **Module scoping** (1-2 weeks): identify boundaries, define contracts
- **Build the new module** (2-4 weeks): domain model, use cases, persistence
- **Data migration** (1-2 weeks): sync data, verify consistency
- **Parallel run** (1-2 weeks): verify correctness under production load
- **Cut over** (1 day): flip the feature flag, monitor closely
- **Clean up** (1 week): remove legacy code, drop old tables

That adds up to roughly 6 to 11 weeks per module.
Repeat for each one.
Later modules go faster because the routing seam, sync tooling, and team habits already exist, and you can pipeline the work (scope the next module while the previous one is in its parallel run).
A system with 5-6 modules usually lands somewhere between six months and a year.

That sounds long, but compare it honestly with a rewrite: the same scope rewritten from scratch takes at least as long, delivers nothing until the end, and carries the risk of never shipping. The strangler fig delivers a migrated, verified module every few weeks.

## Common Mistakes

**Migrating too much at once.** One module at a time. Don't try to migrate orders, payments, and shipping simultaneously.

**Skipping the parallel run.** You need proof the new module is correct before cutting over. Data inconsistencies are expensive to fix.

**Not investing in the routing layer.** The ability to route between old and new implementations per-request is crucial. Feature flags make this safe.

**Ignoring data ownership.** The legacy system and new module should not read from each other's tables directly. Communicate through events or APIs.

## Summary

The Strangler Fig pattern for migrating to a Modular Monolith:

1. Add a routing layer (feature flags)
2. Build one module at a time
3. Sync data between old and new
4. Run both in parallel to verify
5. Cut over and clean up
6. Repeat

No big bang rewrites. No weekends of downtime. Just steady, incremental progress toward a better architecture.

And when the modular monolith itself needs to evolve further, the same playbook applies to [**extracting modules into microservices**](https://milanjovanovic.tech/blog/when-to-extract-module-to-microservice).

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### What is the strangler fig pattern?

The strangler fig pattern replaces a legacy system incrementally. You build new functionality alongside the old system, route traffic to the new implementation piece by piece, and remove legacy code once each piece is proven. The system stays fully functional throughout.

### Why do big rewrites fail?

Rewrites take longer than estimated, the legacy system keeps changing while you rewrite it, and the business sees no value until the very end. Incremental migration delivers value continuously and can be stopped at any point without losing progress.

### How do you route traffic between legacy code and new modules?

Use feature flags to switch per-feature between the legacy implementation and the new module, behind a shared interface. This gives you instant rollback if the new module misbehaves.

### How do you keep data in sync during a strangler fig migration?

Either run a background sync job that copies changed rows from the legacy schema to the new module schema, or publish events from the legacy system that the new module consumes. Stop the sync only after cutover is complete and verified.

### How long does migrating a monolith to a modular monolith take?

A single module typically takes 6 to 11 weeks including scoping, building, data migration, and a parallel-run verification period. With the phases pipelined across modules, a system with 5 or 6 modules commonly takes six months to a year of incremental work.
