Skip to main content
SEMastery
Architectureintermediate

Migrating a Modular Monolith to Microservices in .NET

A simple, friendly guide to moving a .NET modular monolith to microservices using the strangler fig pattern, YARP, clear boundaries, and safe steps.

13 min readUpdated March 17, 2026

A street food stall that grew into a food court

Imagine a small street food stall run by one family.

At first, one stall sells everything. Dosa, chai, samosa, sweets — all from one counter, one stove, one cash box. This is the monolith. It is simple and fast to start. But on a busy evening, the chai line blocks the dosa line. One slow order holds up everyone.

Smart owners fix this first by keeping one stall with clean sections inside. A dosa corner, a chai corner, a sweets corner. Each section has its own counter and its own helper, but it is still one stall the family runs together. This is the modular monolith. Clean walls inside, one thing to run.

Now the stall becomes famous. The chai corner alone needs its own gas line and two extra helpers. The sweets corner wants to open early in the morning before the rest. So the family rents the shop next door and moves the chai corner into its own small shop with its own counter, its own till, and its own staff. Then later the sweets corner too. Slowly the one stall becomes a food court of small shops.

That move — from one shop with sections, to many small shops — is what we mean by migrating a modular monolith to microservices. This article shows you how to do it slowly and safely in .NET, without shutting the kitchen even for a day.

First, be honest: do you even need this?

Microservices are not a prize you win. They are a cost you pay for a reason.

A modular monolith is one app you deploy together, split inside into modules with strong walls. For most teams, that is the right home. You should only break a module out into its own service when you feel a real, sharp pain. Here is a simple table to help you decide.

You feel this painMicroservice helps?Cheaper fix first
One module needs much more CPU or memory than the restYesScale the whole app first
Two teams keep blocking each other on deploysYesCleaner module boundaries
One feature must use a different language or runtimeYesUsually none
"Microservices sound modern"NoStay a monolith
The code is just messy inside one moduleNoRefactor the module

If your reason is in the green rows, read on. If not, fix the cheaper thing and come back later. There is no shame in staying a modular monolith for years.

The big idea: strangle, do not rip

You never rewrite the whole app in one weekend. That is how teams lose months and trust.

Instead you use the strangler fig pattern. In a forest, a fig vine grows around an old tree. Year by year, the vine takes over. One day the old tree is gone, but the fig now stands in its exact shape. Nothing fell down in a single crash.

We do the same with software. We place a router (a reverse proxy) in front of the monolith. Every request goes to the router first. At the start, the router sends everything to the old monolith. Then we build one new service, move one feature into it, and tell the router: "this one path now goes to the new service." We repeat. Slowly the monolith shrinks until it is gone.

The router sits in front. It sends each request to the new service or the old monolith.

The strangler journey

Add router
Move feature 1
Move feature 2
Retire monolith

Steps

1

Add router

Proxy sends all traffic to the monolith

2

Move feature 1

Build first service, reroute its path

3

Move feature 2

Repeat for the next module

4

Retire monolith

Old code is empty, delete it

Each round moves one feature out, until the monolith is empty.

Step 1: Get your boundaries right

You cannot pull out a module that is tangled with everything else. So the real work starts inside the monolith, before any service exists.

A good module talks to other modules only through a small public contract — an interface or a message. It never reaches into another module's tables or private classes. If your modules already follow this rule, you are halfway done. If not, fix this first. This is the single most important step.

Here is what a clean public contract looks like. The Orders module asks the Catalog module a question through an interface. It does not touch Catalog's database.

// Catalog module exposes ONLY this to the outside world.
public interface ICatalogApi
{
    Task<ProductInfo?> GetProductAsync(Guid productId, CancellationToken ct);
}
 
public sealed record ProductInfo(Guid Id, string Name, decimal Price);
 
// Inside the Orders module, we depend on the contract, not on Catalog internals.
public sealed class PlaceOrderHandler(ICatalogApi catalog)
{
    public async Task<Result> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var product = await catalog.GetProductAsync(cmd.ProductId, ct);
        if (product is null)
            return Result.Fail("Product not found");
 
        // ... create the order using product.Price
        return Result.Ok();
    }
}

Why does this matter so much? Because today ICatalogApi is just a method call inside the same app. Tomorrow, when Catalog becomes its own service, you swap the implementation for one that makes an HTTP or message call. The Orders code that uses it does not change at all. The seam was already there.

Modules talk only through public contracts, never through private data.

Step 2: Put a router in front with YARP

In .NET, the easy choice for the router is YARP (Yet Another Reverse Proxy). It is a free, official Microsoft library. You build a tiny ASP.NET Core app whose only job is to forward requests.

At the start, every route points to the monolith. Here is a small YARP config.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddReverseProxy()
    .LoadFromMemory(
        routes:
        [
            // New: /orders goes to the new service
            new RouteConfig
            {
                RouteId = "orders",
                ClusterId = "orders-service",
                Match = new RouteMatch { Path = "/orders/{**rest}" }
            },
            // Everything else still goes to the old monolith
            new RouteConfig
            {
                RouteId = "fallback",
                ClusterId = "monolith",
                Match = new RouteMatch { Path = "/{**catch-all}" }
            }
        ],
        clusters:
        [
            new ClusterConfig
            {
                ClusterId = "orders-service",
                Destinations = new Dictionary<string, DestinationConfig>
                {
                    ["d1"] = new() { Address = "https://orders.internal/" }
                }
            },
            new ClusterConfig
            {
                ClusterId = "monolith",
                Destinations = new Dictionary<string, DestinationConfig>
                {
                    ["d1"] = new() { Address = "https://monolith.internal/" }
                }
            }
        ]);
 
var app = builder.Build();
app.MapReverseProxy();
app.Run();

The key trick is order. YARP matches the most specific route first. So /orders/... is caught by the new service, and the catch-all /{**catch-all} sweeps up everything else into the monolith. To migrate the next feature, you add one more route. The user never sees a difference. The web address stays the same.

Step 3: Move one module out

Pick your first service carefully. Choose a module that is already well walled off, not too central, and has a clear reason to leave (it scales differently, or a separate team owns it). Orders, Notifications, or Reporting are common first picks.

The move has four parts:

  1. Copy the module code into a new ASP.NET Core project. Because it was a clean module, this is mostly copy-paste plus a thin web layer.
  2. Give it its own database. This is the hard part, and we cover it next. The new service must own its data.
  3. Point the router at the new service for that module's paths.
  4. Delete the old code from the monolith once you trust the new service.

Moving one module

Pick module
Own its data
Reroute path
Delete old code

Steps

1

Pick module

Well-walled, clear reason to leave

2

Own its data

New DB or schema, no shared tables

3

Reroute path

Add a YARP route to the new service

4

Delete old code

Remove the module from the monolith

Repeat these steps for each module you pull out.

Step 4: The hardest part — splitting the data

In the monolith, all modules often share one database. When a module leaves, it must take its data with it and stop reaching into other tables. This is where most migrations get stuck, so go slowly.

The rule is simple to say: each service owns its own data. No service reads or writes another service's tables directly. If the Orders service needs a product name, it asks the Catalog service. It does not run a SELECT on the Catalog tables.

But what about a join you used to do in one query? You now have two choices, shown below.

Old monolith habitNew microservice wayWhen to use
JOIN across module tablesCall the other service's APISmall, fresh data needed live
Read another module's tableSubscribe to its events, keep a local copyHigh traffic, can be slightly stale
One transaction across modulesSaga (a chain of steps with undo)A multi-step process must stay consistent

For data that does not change every second, the event way is often best. When Catalog changes a product, it publishes a message. The Orders service listens and stores just the small bits it needs (like the product name and price) in its own database. Now Orders can work even if Catalog is busy.

// Catalog service publishes an event when a product changes.
public sealed record ProductPriceChanged(Guid ProductId, decimal NewPrice);
 
// Orders service listens and updates its own local copy.
public sealed class ProductPriceChangedConsumer(OrdersDbContext db)
{
    public async Task HandleAsync(ProductPriceChanged evt, CancellationToken ct)
    {
        var local = await db.ProductCache.FindAsync([evt.ProductId], ct);
        if (local is not null)
        {
            local.Price = evt.NewPrice;
            await db.SaveChangesAsync(ct);
        }
    }
}

You can carry these events with any message broker, such as RabbitMQ or Azure Service Bus. A quick honesty note: popular helper libraries like MassTransit and MediatR are now commercial products that need a paid license for many teams. You do not need them to do this. You can use the free broker client directly, as the code above hints. The pattern, not the library, is what matters.

Services stay loosely joined by sending events through a broker.

Step 5: Handle the new troubles

Once data is split, the network becomes a real character in your story. The food court has more shops, but now staff must walk between them. Plan for these.

  • Things fail mid-way. A call can time out. Use retries with a small wait, and a circuit breaker so one slow service does not freeze the rest. .NET's Microsoft.Extensions.Http.Resilience gives you these for free.
  • You cannot follow one log file anymore. A single user click may now touch three services. Use OpenTelemetry to stitch the logs together with one trace id, so you can follow the whole journey.
  • Two services must agree without one transaction. Use a saga: a chain of small steps where each step has an undo. If step three fails, you run the undos for steps two and one.
  • Data is slightly behind. With events, the Orders copy of a price may lag by a second. Decide if that is fine for each feature. Often it is.

Here is the shape of resilient calls in .NET, so a flaky network does not take you down.

builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal/");
})
.AddStandardResilienceHandler(); // retries, timeout, circuit breaker built in

That one line, AddStandardResilienceHandler, wraps your calls with sensible retries, a timeout, and a circuit breaker. It turns a fragile call into a sturdy one.

A picture of the finished food court

After several rounds, your app looks like a small food court. A gateway in front. A few services behind, each with its own data. A broker carrying events between them. The old monolith is either gone or shrunk to a tiny core.

The shape after migration: a gateway, a few services, each owning its data.

Notice you did not need to reach this picture in one jump. You reached it one calm step at a time, and the app served users the whole way.

A simple plan you can follow

Put together, here is the order of work for a real team. Keep each step small and shippable.

  1. Clean your module walls inside the monolith. Talk only through public contracts.
  2. Give each module its own schema, even while still one app. This rehearses the data split.
  3. Add a YARP gateway in front. Send all traffic to the monolith at first.
  4. Pick one well-walled module. Build it as a new service with its own database.
  5. Reroute its paths in the gateway. Watch logs and metrics closely.
  6. Add resilience and tracing so failures are visible and survivable.
  7. Delete the old module from the monolith. Celebrate. Repeat for the next one.

Stop whenever the pain stops. You do not have to extract every module. Many strong teams keep a small core monolith forever and only pull out the few modules that truly need it.

Quick recap

  • A modular monolith is one app with clean inside walls. Microservices are many small apps. Move between them only for a real reason.
  • Use the strangler fig pattern: put a router in front and move one feature at a time. Never rewrite all at once.
  • YARP is a free .NET reverse proxy that routes each path to either the new service or the old monolith.
  • The most important prep work is clean boundaries: modules talk only through public contracts, never through private tables.
  • The hardest step is splitting data. Each service owns its data. Share through API calls or events, not shared tables.
  • Plan for new troubles: retries, circuit breakers, tracing, and sagas. .NET resilience and OpenTelemetry help a lot.
  • MediatR and MassTransit are now commercial. You can do all of this with free tools and direct broker clients.
  • Stop migrating when the pain stops. A small core monolith plus a few services is a perfectly good home.

References and further reading

Related Posts