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.
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 pain | Microservice helps? | Cheaper fix first |
|---|---|---|
| One module needs much more CPU or memory than the rest | Yes | Scale the whole app first |
| Two teams keep blocking each other on deploys | Yes | Cleaner module boundaries |
| One feature must use a different language or runtime | Yes | Usually none |
| "Microservices sound modern" | No | Stay a monolith |
| The code is just messy inside one module | No | Refactor 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 strangler journey
Steps
Add router
Proxy sends all traffic to the monolith
Move feature 1
Build first service, reroute its path
Move feature 2
Repeat for the next module
Retire monolith
Old code is empty, delete it
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.
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:
- 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.
- Give it its own database. This is the hard part, and we cover it next. The new service must own its data.
- Point the router at the new service for that module's paths.
- Delete the old code from the monolith once you trust the new service.
Moving one module
Steps
Pick module
Well-walled, clear reason to leave
Own its data
New DB or schema, no shared tables
Reroute path
Add a YARP route to the new service
Delete old code
Remove the module from the monolith
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 habit | New microservice way | When to use |
|---|---|---|
JOIN across module tables | Call the other service's API | Small, fresh data needed live |
| Read another module's table | Subscribe to its events, keep a local copy | High traffic, can be slightly stale |
| One transaction across modules | Saga (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.
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.Resiliencegives 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 inThat 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.
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.
- Clean your module walls inside the monolith. Talk only through public contracts.
- Give each module its own schema, even while still one app. This rehearses the data split.
- Add a YARP gateway in front. Send all traffic to the monolith at first.
- Pick one well-walled module. Build it as a new service with its own database.
- Reroute its paths in the gateway. Watch logs and metrics closely.
- Add resilience and tracing so failures are visible and survivable.
- 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
- Strangler Fig Pattern — Azure Architecture Center (Microsoft Learn)
- .NET microservices architecture guidance — Microsoft Learn
- Design patterns for microservices — Azure Architecture Center
- The Strangler Fig application pattern — microservices.io (Chris Richardson)
Related Posts
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
Building a Modular Monolith With Vertical Slice Architecture in .NET
Learn to build a modular monolith using vertical slice architecture in .NET. Simple words, real-life analogy, diagrams, tables, and clean C# code examples.
Monolith to Microservices: How a Modular Monolith Helps
Learn how a modular monolith makes the move from monolith to microservices safe and easy in .NET, using clean boundaries, the Strangler Fig pattern, and small steps.
Breaking It Down: How to Migrate Your Modular Monolith to Microservices
A friendly, step-by-step guide to safely move from a .NET modular monolith to microservices using the Strangler Fig pattern, without a risky big-bang rewrite.
Implementing API Gateway Authentication With YARP in .NET
Learn to build a secure API gateway in .NET using YARP. Add authentication, per-route authorization policies, and pass user identity to backend services.
Synchronous vs Asynchronous Communication in Microservices (.NET Guide)
A simple, friendly guide to synchronous vs asynchronous communication in microservices, with .NET examples, diagrams, tables, and clear rules on when to use each.