BrandGhost
Strangler Fig Pattern in C#: Refactoring a Monolith to Microservices

Strangler Fig Pattern in C#: Refactoring a Monolith to Microservices

The hardest part of microservices migration isn't the code -- it's doing it without breaking production. Teams get stuck because there's no safe way to make the leap all at once. You can't stop the world and rewrite everything. That's where the strangler fig pattern in C# comes in -- it gives you a proven, incremental approach that lets you migrate piece by piece while keeping the lights on.

This guide walks you through the full strangler fig pattern migration process in .NET, with real working code from a three-step example project. You'll see the monolith before state, the YARP facade in the middle, and the fully extracted microservices at the end. No hand-waving, no pseudocode -- actual runnable examples you can adapt.

If you've worked with design patterns before, you know that patterns like the Decorator design pattern in C# or the Strategy design pattern in C# solve problems at the class and module level. The strangler fig pattern operates at the architecture level -- it's the same discipline applied to entire systems.

What Is the Strangler Fig Pattern?

The strangler fig pattern is a migration strategy named after the strangler fig tree (Ficus aurea), which germinates in the canopy of a host tree and slowly grows downward, wrapping around the trunk. Over time, the fig's roots reach the ground and it becomes self-supporting. Eventually the host tree dies and the fig tree stands on its own, having grown around -- and replaced -- its host.

In software, the metaphor maps cleanly. Your legacy monolith is the host tree. Each new microservice is a branch of the strangler fig. You grow new functionality in parallel with the existing system. The legacy code is progressively replaced, route by route, domain by domain, until nothing remains of the original monolith.

Martin Fowler coined the term in 2004, and it has become one of the most widely adopted patterns for modernizing legacy systems. The core idea is simple: rather than replacing the entire system at once, you incrementally extract and replace pieces while the rest of the system continues to run normally.

Why Incremental Migration Beats Big-Bang Rewrite

Big-bang rewrites fail at a high rate. The new system takes longer to build than expected. Business requirements change during the build. Edge cases in the original system are undiscovered until it's too late. You spend months building a replacement only to find it missing critical behaviors that were embedded deep in the old code.

Incremental migration with the strangler fig pattern avoids these failure modes. Every step is independently deployable and independently testable. If something goes wrong with one extracted service, you roll it back and the monolith picks up the slack. You're never in a state where nothing works.

There are other structural benefits as well. The strangler fig pattern forces you to define clear service boundaries before extraction -- which itself produces better architecture. It gives you a working production deployment at every stage. And it lets you validate each extracted service under real load before proceeding to the next one.

Just like the Observer design pattern in C# decouples producers from consumers at the code level, the strangler fig pattern decouples your deployment and migration concerns at the architecture level. You move forward without requiring a full-system cutover.

The Three-Step Migration Process in C#

The migration follows a clear three-stage progression:

  1. Step 1 -- The Monolith: A single ASP.NET Core app serving Products, Orders, and Customers from one shared database
  2. Step 2 -- Strangler Facade: Products extracted to its own service, with a YARP reverse proxy routing traffic transparently
  3. Step 3 -- Full Extraction: All three domains running as independent microservices with isolated databases

Each step is a complete, runnable system. You don't skip from Step 1 to Step 3. You build Step 2 first, validate it, then proceed to Step 3. That's the discipline the strangler fig pattern enforces.

Step 1: Identify the Extraction Candidate

Not every module is a good first candidate for extraction. You want a module with high internal cohesion and low coupling to everything else. Products is a strong first candidate in our example because it doesn't depend on Orders or Customers -- it's relatively self-contained.

Here's the full monolith. All three domains share a single AppDbContext and run in one ASP.NET Core process:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("Data Source=legacyapp.db"));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.EnsureCreated();
}

// Products endpoints
app.MapGet("/products", async (AppDbContext db) =>
{
    return await db.Products.ToListAsync();
});

app.MapPost("/products", async (Product product, AppDbContext db) =>
{
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/products/{product.Id}", product);
});

// Orders endpoints
app.MapGet("/orders", async (AppDbContext db) =>
{
    return await db.Orders.ToListAsync();
});

app.MapPost("/orders", async (Order order, AppDbContext db) =>
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    return Results.Created($"/orders/{order.Id}", order);
});

// Customers endpoints
app.MapGet("/customers", async (AppDbContext db) =>
{
    return await db.Customers.ToListAsync();
});

app.MapPost("/customers", async (Customer customer, AppDbContext db) =>
{
    db.Customers.Add(customer);
    await db.SaveChangesAsync();
    return Results.Created($"/customers/{customer.Id}", customer);
});

app.Run();

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public decimal TotalAmount { get; set; }
    public DateTime OrderDate { get; set; } = DateTime.UtcNow;
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

Notice the problem: every domain shares the same AppDbContext. To change the Product schema you need to touch the same database migration that Order and Customer depend on. To deploy a Products feature you deploy the entire application. This is the coupling the strangler fig pattern is designed to break.

Step 2: Extract the Service

Once you've identified Products as the extraction candidate, the next move is to build Products as a standalone ASP.NET Core service with its own database context. The contract (routes, request/response shapes) stays the same -- clients shouldn't notice any difference after the switch.

Here's the extracted Products.Service with its own ProductsDbContext:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ProductsDbContext>(options =>
    options.UseSqlite("Data Source=products.db"));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ProductsDbContext>();
    db.Database.EnsureCreated();
}

// This is the EXTRACTED Products microservice.
// It has been pulled out from the monolith and now runs independently.
// It owns the Products domain with its own database.

app.MapGet("/products", async (ProductsDbContext db) =>
{
    return await db.Products.ToListAsync();
});

app.MapPost("/products", async (Product product, ProductsDbContext db) =>
{
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/products/{product.Id}", product);
});

app.Run();

public class ProductsDbContext : DbContext
{
    public ProductsDbContext(DbContextOptions<ProductsDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

The routes are identical to what the monolith exposed. The difference is that ProductsDbContext only knows about Product -- it has no knowledge of Order or Customer. This service owns its data, runs on its own port (5002 in our example), and can be deployed and scaled independently.

The legacy app is simultaneously updated to remove Products entirely. Its AppDbContext no longer has a Products DbSet, and the /products endpoints are gone. This is important -- the monolith must hand off ownership cleanly.

Step 3: Add the Strangler Facade

This is where the strangler fig pattern gets its power. You now have two systems that can serve products traffic: the old monolith (which no longer has Products) and the new Products.Service. Clients hit a single entry point -- the strangler facade -- and they never need to know the difference.

The facade is a YARP-based reverse proxy. The Program.cs is almost nothing:

var builder = WebApplication.CreateBuilder(args);

// This is the STRANGLER FACADE using YARP.
// It acts as a reverse proxy that routes requests to either:
// - The new Products.Service (for /products/**)
// - The legacy monolith (for everything else)
//
// This allows us to incrementally migrate functionality while maintaining
// a single entry point for clients.

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.MapReverseProxy();

app.Run();

All the routing logic lives in appsettings.json:

{
  "Urls": "http://localhost:5000",
  "ReverseProxy": {
    "Routes": {
      "products-route": {
        "ClusterId": "products-cluster",
        "Match": {
          "Path": "/products/{**catch-all}"
        }
      },
      "legacy-route": {
        "ClusterId": "legacy-cluster",
        "Match": {
          "Path": "/{**catch-all}"
        },
        "Order": 100
      }
    },
    "Clusters": {
      "products-cluster": {
        "Destinations": {
          "products-service": {
            "Address": "http://localhost:5002"
          }
        }
      },
      "legacy-cluster": {
        "Destinations": {
          "legacy-app": {
            "Address": "http://localhost:5001"
          }
        }
      }
    }
  }
}

Read that routing config carefully. Any request matching /products/{**catch-all} goes to products-cluster (port 5002). Everything else falls through to legacy-cluster (port 5001) because the legacy-route has Order: 100 -- a lower priority than the products route. Clients hit port 5000 and have no idea their products request is being served by a completely new system.

This is exactly what makes the strangler fig pattern safe. If the new Products service has a critical bug, you update the YARP config to stop routing to it. Traffic falls back to the monolith instantly. No data loss, no downtime, no emergency rollback of a full deployment.

YARP (Yet Another Reverse Proxy) is a .NET-native reverse proxy library built by Microsoft that's purpose-built for these scenarios. It integrates directly with ASP.NET Core's middleware pipeline and supports configuration reload without restart. For more about background service patterns in .NET that complement this architecture, see Hosted Services with Needlr: Background Workers and Lifecycle Management.

Step 4: Cut Over Traffic and Validate

With the facade in place, you have a safe environment to validate the new service. Run both systems and send traffic through the facade. Test the Products endpoints at http://localhost:5000/products -- requests are silently proxied to http://localhost:5002/products. Verify that:

  • GET /products returns the correct data
  • POST /products creates records in the new Products database
  • Orders and Customers endpoints (handled by the legacy app) are unaffected

Once you're confident the new service is handling load correctly, you can decomission the Products-related code from the legacy app. You've already removed it in Step 2, so this is simply confirming that no traffic is reaching it. The YARP route config is your kill switch -- you control which service handles which paths.

Step 5: Repeat Until Done

Once Products is stable, you apply the same pattern to Orders and Customers. Extract each domain to its own service, update the YARP config to route its paths to the new service, validate, and continue.

After full extraction, the final architecture looks like this:

  • Products.Service (port 5002) -- owns products.db, handles all /products traffic
  • Orders.Service (port 5003) -- owns orders.db, handles all /orders traffic
  • Customers.Service (port 5004) -- owns customers.db, handles all /customers traffic

Each service has its own DbContext:

// Orders.Service
public class OrdersDbContext : DbContext
{
    public OrdersDbContext(DbContextOptions<OrdersDbContext> options) : base(options) { }

    public DbSet<Order> Orders => Set<Order>();
}

// Customers.Service
public class CustomersDbContext : DbContext
{
    public CustomersDbContext(DbContextOptions<CustomersDbContext> options) : base(options) { }

    public DbSet<Customer> Customers => Set<Customer>();
}

At this point the original monolith is fully replaced. The strangler fig has rooted. In production you'd typically keep the YARP facade running as your API gateway, or replace it with a more capable solution like Azure API Management or Kong. The facade itself can serve as the foundation for cross-cutting concerns: authentication, rate limiting, distributed tracing, and health checks.

Common Pitfalls

Knowing how to apply the strangler fig pattern in C# is only half the battle. These are the pitfalls that cause migrations to stall or fail.

Shared database between old and new. The biggest trap. If your extracted service and the monolith share the same database, you haven't actually decoupled them -- you've just moved the code while preserving the coupling at the data layer. Each extracted service must own its own database schema. Data migration is harder than code migration, but it's non-negotiable for real independence.

Synchronous dependency chains. If the new Products service synchronously calls the Orders service (or the monolith) to complete a request, you've introduced a new form of tight coupling. Extract services that can fulfill their responsibilities without synchronous calls to other services. If inter-service communication is genuinely required, design for asynchronous messaging from the start.

Premature extraction. Don't extract a domain before you understand its true boundaries. The monolith knows things about its own behavior that you'll only discover by reading the code carefully. If you extract prematurely and miss a dependency, you'll find it in production at the worst possible moment.

Skipping the facade. Some teams try to skip directly to extracted services by updating client configurations. This creates a hard cutover -- exactly what the strangler fig pattern is designed to avoid. Always use a facade. The operational cost of running YARP is negligible compared to the safety it provides.

Design patterns like Factory Method in C# and the Builder design pattern in C# help structure the service creation code cleanly once you're building out each extracted service -- but they can't rescue you from a poorly planned extraction boundary.

When NOT to Use Strangler Fig

The strangler fig pattern is not always the right choice. Consider these situations where it may be overkill or inappropriate.

Small codebase. If the monolith is a few thousand lines of well-structured code, extracting to microservices with a facade introduces more complexity than it solves. The pattern exists to manage migration risk. If the risk is low, the pattern overhead isn't worth it.

No operational maturity. Running multiple services requires infrastructure: separate deployments, health checks, distributed logging, and service discovery. If your team doesn't have operational experience running distributed systems, the strangler fig migration will expose those gaps in the most painful way -- in production, under load.

Team too small. Microservices work well when different teams own different services. If one team maintains everything, the operational overhead of multiple services, multiple databases, and a routing layer can slow down development compared to a well-organized monolith.

Tight temporal coupling between domains. If your Products, Orders, and Customers data changes must all succeed or all fail together in a single transaction, extracting them to separate services introduces distributed transaction complexity that's significantly harder to manage than shared database transactions.

If you're unsure which pattern best fits your architecture decisions, the same analytical lens that applies to class-level patterns like Observer vs Mediator pattern in C# applies here: understand the tradeoffs before committing, and don't use a pattern just because it's popular.

Similarly, the Singleton design pattern in C# is often misused because it's familiar -- not because it's the right fit. The same overuse risk applies to microservices architectures.

FAQ

What is the strangler fig pattern in C#?

The strangler fig pattern in C# is an architectural migration strategy where you incrementally replace a monolithic application by building new microservices alongside it. A routing facade (typically using YARP in .NET) transparently redirects traffic to new services as they are extracted, leaving the monolith to handle only the domains not yet migrated. Over time the monolith is fully replaced without a big-bang cutover.

How does YARP enable the strangler fig pattern in .NET?

YARP (Yet Another Reverse Proxy) acts as the strangler facade. It's a .NET library that integrates with ASP.NET Core and routes incoming HTTP traffic to different backends based on route patterns. In a strangler fig migration, you configure YARP to route extracted domain paths to new services while everything else falls through to the legacy monolith. Clients see one entry point; YARP handles the routing transparently.

How do I handle the shared database when extracting a microservice?

Each extracted service must own its own database. In practice this means creating a new database and DbContext for the extracted service, migrating the relevant data, and removing that data from the monolith's shared database. This data migration is the hardest part of the extraction -- but it's required to achieve true decoupling. A service that still reads from the monolith's database is not actually independent.

How do I know which domain to extract first?

Start with the domain that has the highest cohesion and lowest coupling -- meaning it doesn't need data or synchronous calls from other domains to fulfill its responsibilities. Products is a good first candidate in most e-commerce systems. A domain that calls into multiple other domains or participates in shared transactions is a poor first candidate and should be extracted later, after boundaries are cleaner.

Can I use the strangler fig pattern for just part of the application?

Yes. You don't have to fully extract every domain. The strangler fig pattern is additive -- you can extract the domains that benefit most from independent deployment and scaling while leaving stable, low-change domains in the monolith indefinitely. Some teams never reach full extraction and that's fine if the result is a well-partitioned system.

What happens to the YARP facade after migration is complete?

In production you have several options. You can keep YARP running as your API gateway, which gives you a central place for cross-cutting concerns like authentication, rate limiting, and observability. You can replace it with a more capable API gateway like Azure API Management, Kong, or an Envoy-based service mesh. Or -- for simpler systems -- you can update client configurations to point directly to each service and decommission the facade.

How do I validate that the extracted service is working correctly before cutting over?

Deploy the strangler facade and run it in shadow mode -- route all traffic to the monolith while simultaneously logging what the new service would return. Compare responses. Alternatively, use the YARP config to route a small percentage of traffic to the new service (canary deployment) while monitoring for errors and discrepancies. This validation phase is critical and shouldn't be rushed.

Conclusion

The strangler fig pattern in C# gives you a structured, low-risk path from monolith to microservices. The key insight is that migration is not an event -- it's a process. You build new services alongside the old system, route traffic incrementally, validate at each step, and repeat until the monolith is fully replaced.

The three-step example in this guide follows that process exactly. Step 1 is the monolith: all domains in one process, one database, one deployment. Step 2 is the transition: Products extracted, YARP routing requests transparently, the monolith still running for Orders and Customers. Step 3 is the finish line: all three services independent, the monolith gone, each service owning its data and its deployment.

Apply this pattern to your own monolith by starting small. Pick the most isolated domain. Extract it. Put the facade in front. Validate. Then repeat. The code is simple -- YARP configuration and a minimal ASP.NET Core app per service. The discipline is the harder part, and that's what the strangler fig pattern enforces.

How to Build a Modular Monolith in C# from Scratch: Step-by-Step Guide

Learn how to build a modular monolith in C# from scratch with this step-by-step guide. Create bounded context modules, enforce isolation, and wire them together in .NET 9.

Monolith vs Microservices in C#: A Decision Framework for .NET Developers

Cut through the hype with a practical decision framework for choosing between monolith vs microservices in C#. Learn when each architecture wins for .NET developers.

Distributed Monolith: The Anti-Pattern Every C# Developer Should Recognize

Learn what a distributed monolith is in C#, why it's worse than a traditional monolith or microservices, and how to identify and fix this common architectural anti-pattern.

An error has occurred. This application may no longer respond until reloaded. Reload