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.
Moving house, one room at a time
Imagine your family lives in a single big house. Over the years it has become crowded. The kitchen is too small for everyone cooking during Diwali, and the study room is always noisy when guests arrive. You decide some rooms need their own separate space.
Now, you have two choices.
The scary way: knock down the whole house in one day and rebuild everything from scratch. If anything goes wrong, your family has nowhere to sleep tonight. One mistake and everyone suffers.
The calm way: move out one room at a time. First you build a small separate cottage for the study, move the books and desk there, and check that it works. The rest of the family keeps living in the main house, eating and sleeping as normal. Next month you move the kitchen. Step by step, the busy rooms get their own space, and nobody ever sleeps on the street.
Going from a monolith to microservices is exactly like this. You do not rebuild everything at once. You move one piece at a time, safely. And the secret that makes this calm instead of scary is a modular monolith — a house that already has proper rooms with proper walls.
Let us see why those walls matter so much.
First, a quick reminder of the three shapes
Before we talk about moving, let us remember what we are moving between.
Three Shapes of an Application
Steps
Tangled Monolith
One app, no clear walls, code mixed together
Modular Monolith
One app, strong module walls, clean contracts
Microservices
Many apps, each its own service over the network
The big lesson hidden here: walls and deployment are two different things.
A tangled monolith has neither walls nor separate deployment. Microservices have both. A modular monolith sits in the middle: it has the walls but not the separate deployment. It is one program you ship as a single unit, but inside, the parts are kept cleanly apart.
That middle position is gold. It means you can build the hard part (clean boundaries) now, while running cheaply and simply, and split into separate services later only if you truly need to.
Why splitting a tangled monolith hurts
People often dream of jumping straight from a tangled monolith to microservices. It usually goes badly. Here is why.
In a tangled monolith, the code has no walls. The Orders code reaches directly into the Customers tables. The Billing code calls a private method inside Shipping. Everything touches everything.
Now try to pull "Billing" out into its own service. You quickly find that Billing reads from five other tables, and three other parts call hidden methods inside it. To cut Billing free, you must untangle all of that while the app is live. It is like trying to remove one thread from a tightly knotted ball of wool. Pull one thread and the whole thing tightens.
This is the real reason microservice migrations fail. The problem is not the network or the tools. The problem is that there were no clean lines to cut along.
How a modular monolith fixes this
A modular monolith forces you to draw those clean lines before you ever think about microservices. Each module:
- Owns its own area of the business (a bounded context).
- Owns its own data — its own tables that nobody else touches.
- Exposes only a small public contract. Other modules talk to it through that contract, never by reaching inside.
Look at the difference. The arrows now go through doors (public contracts), not through walls. Each module is already a clean, self-contained shape.
So when the day comes to make Billing its own service, the wall is already there. You mostly change how modules talk — from an in-memory call to a network call — not what they say. The shape stays the same. That is the whole gift of a modular monolith.
Here is what a clean public contract looks like in .NET. The Orders module never sees the inside of Billing. It only sees this interface.
// This lives in a shared "contracts" project.
// Both the monolith and a future microservice can implement it.
public interface IBillingService
{
Task<InvoiceResult> CreateInvoiceAsync(
CreateInvoiceRequest request,
CancellationToken cancellationToken = default);
}
public sealed record CreateInvoiceRequest(Guid OrderId, decimal Amount, string Currency);
public sealed record InvoiceResult(Guid InvoiceId, string Status);Inside the monolith today, this interface is implemented by a normal class and called in-process. Tomorrow, the same interface can be implemented by a thin client that calls a real Billing microservice over the network. The callers do not change at all.
The plan: a calm, step-by-step move
When you do decide to move, you do not do it all at once. You use a famous, gentle approach called the Strangler Fig pattern.
The name comes from a real plant. A strangler fig grows slowly around a host tree. Over many years it wraps the tree completely. In the end, the old tree rots away and the fig stands on its own, in the exact same shape. Your migration works the same way: the new services grow around the old monolith until the monolith can quietly disappear.
The Strangler Fig Migration
Steps
Add a Gateway
Route all traffic through one front door
Pick One Module
Choose a module with a clean boundary
Build a Service
Move that module into its own service
Reroute Traffic
Point its routes to the new service
Repeat
Do the next module, keep the rest running
The key idea is that the old monolith keeps running the whole time. You are never offline. Every other feature still works while you carefully move one piece.
Let us walk through each step.
Step 1: Put a gateway in front
First you place an API Gateway in front of your app. This is one front door that all requests pass through. At the start, the gateway simply sends everything to the monolith, so nothing changes for users.
But now you have a switch you control. When a new service is ready, you flip one route at the gateway to point at the new service. Users never notice.
Step 2: Pick the right module first
Do not start with the most important module. Start with one that is:
- Loosely coupled — it does not depend on many others.
- Clearly bounded — it owns its own data.
- A real win — for example, it needs to scale on its own, or a separate team wants to own it.
A good first pick is often something like notifications, search, or reporting. These tend to sit at the edge and rarely block the core flow.
Step 3: Replace in-process calls with network calls
Inside the monolith, modules call each other in memory. That is fast and simple. Once a module moves out, those calls must travel over the network.
For request-and-reply calls, gRPC is a great fit in .NET. It is fast and gives you strong, typed contracts. Here is a small gRPC client that implements the same IBillingService interface from before, so the rest of the app does not change.
public sealed class GrpcBillingClient : IBillingService
{
private readonly Billing.BillingClient _client;
public GrpcBillingClient(Billing.BillingClient client) => _client = client;
public async Task<InvoiceResult> CreateInvoiceAsync(
CreateInvoiceRequest request,
CancellationToken cancellationToken = default)
{
var reply = await _client.CreateInvoiceAsync(
new CreateInvoiceProto
{
OrderId = request.OrderId.ToString(),
Amount = (double)request.Amount,
Currency = request.Currency
},
cancellationToken: cancellationToken);
return new InvoiceResult(Guid.Parse(reply.InvoiceId), reply.Status);
}
}Notice the trick: the Orders module still calls IBillingService.CreateInvoiceAsync. It has no idea that the call now crosses the network. We only swapped the implementation in the dependency injection container.
Step 4: Use messages for things that can wait
Not every call needs an instant answer. When Orders finishes, it might tell Shipping "an order was placed." Shipping does not need to reply right away. For this, use asynchronous messaging with a message broker like RabbitMQ or Azure Service Bus.
Messaging makes services loosely coupled. If Shipping is busy or briefly down, the message waits in the queue and is handled later. Nothing is lost.
// In the Orders service: announce that something happened, then move on.
public sealed class OrderPlacedPublisher
{
private readonly IMessageBus _bus;
public OrderPlacedPublisher(IMessageBus bus) => _bus = bus;
public Task PublishAsync(Guid orderId, Guid customerId)
{
// Fire the event. We do not wait for Shipping to finish.
return _bus.PublishAsync(new OrderPlaced(orderId, customerId, DateTime.UtcNow));
}
}
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, DateTime OccurredAt);A quick honesty note for 2026: some popular .NET libraries for this, like MediatR and MassTransit, are now under commercial licenses for many uses. They are still excellent, but check the license and your budget before adopting them. You can also use the raw client libraries (for example, the Azure Service Bus SDK or the RabbitMQ client) directly, which stay free.
Step 5: Give the new service its own database
This is a rule people often break, and then they suffer. Each service owns its own data. No other service reaches into its tables. They talk through APIs and messages only.
A modular monolith makes this easy because each module should already own its own tables. So moving the data is usually a matter of moving those tables (or that schema) to the new service's own database.
A side-by-side comparison
It helps to see the trade-offs plainly. Here is how the three shapes compare on the things that matter day to day.
| Concern | Tangled Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Inner boundaries | None | Strong | Strong |
| How to run it | One app | One app | Many apps |
| Operational cost | Low | Low | High |
| Independent scaling | No | No | Yes |
| Easy to split later | Very hard | Easy | Already split |
| Team independence | Low | Medium | High |
| Risk of failure spreading | High | Medium | Lower (isolated) |
And here is a small guide for choosing which call style to use once modules become services.
| Need | Best choice | Why |
|---|---|---|
| Instant request and reply | gRPC or REST | Caller needs the answer now |
| Fire and forget event | Messaging (queue) | Receiver can handle it later, stays loosely coupled |
| Heavy read traffic | REST plus a cache | Simple, cache-friendly, easy to scale reads |
| Internal high-speed calls | gRPC | Fast, typed contracts between services |
Watch out for the hidden costs
Microservices are not free magic. Once your call crosses the network, new problems appear that did not exist inside one process:
- The network can fail. A call might time out or get lost. You now need retries and timeouts.
- Things happen out of order. Messages may arrive late or twice. Your handlers must be safe to run twice (this is called being idempotent).
- Debugging is harder. A single user action may now touch four services. You need distributed tracing (OpenTelemetry is the standard in .NET) to follow the trail.
- Data is spread out. You can no longer do one neat database transaction across everything. You handle this with patterns like the Saga and the Outbox.
This is exactly why you should not rush. Stay a modular monolith as long as it serves you. Move a module out only when a real, named pain pushes you — like "this one part gets ten times the traffic of the rest and we need to scale it alone."
A simple decision flow
When someone says "let us go microservices," walk through this calm checklist first.
Notice that almost every path can end at "stay a modular monolith." That is the point. The modular monolith is a wonderful place to live, not just a waiting room for microservices. You move out only the few modules that truly need it, and you keep the rest cozy inside the house.
A short worked example
Say your shop app is a modular monolith with these modules: Catalog, Orders, Billing, and Notifications. Black Friday is coming. You notice Notifications (sending emails and SMS) gets huge bursts and sometimes slows down the whole app.
Here is your plan:
- Notifications already owns its own tables and only listens to events like
OrderPlaced. Good — the wall is clean. - Build a small Notifications service. It subscribes to the same
OrderPlacedmessage from the broker. - In the monolith, stop handling notifications in-process. The event now flows to the new service through the queue.
- The new service can scale up to many copies during the rush, while the rest of the app stays as one calm monolith.
// In the new Notifications microservice: handle the event on its own.
public sealed class OrderPlacedHandler
{
private readonly IEmailSender _email;
public OrderPlacedHandler(IEmailSender email) => _email = email;
public async Task HandleAsync(OrderPlaced message, CancellationToken ct)
{
// Safe to run twice: check before sending.
if (await _email.AlreadySentAsync(message.OrderId, ct))
return;
await _email.SendOrderConfirmationAsync(message.CustomerId, message.OrderId, ct);
}
}You moved one module. Everything else stayed put. No big-bang rewrite, no scary night where the whole shop is offline. That is the modular monolith doing its job: it made a big change feel like a small one.
References and further reading
- Strangler Fig pattern — Microsoft Azure Architecture Center
- Rebuild monolithic applications using microservices — Microsoft Learn
- .NET application architecture guidance — Microsoft Learn
- Breaking It Down: How to Migrate Your Modular Monolith to Microservices — Milan Jovanović
- Migrating a Modular Monolith to Microservices in .NET — Anton DevTips
Quick recap
- Do not rebuild everything at once. Move one room at a time, like shifting a busy house bit by bit.
- A modular monolith is one app with strong inner walls. The walls are the hard part, and it builds them early.
- Those walls (bounded contexts, own data, public contracts) are the clean lines you cut along later. Tangled monoliths have none, which is why their migrations fail.
- The Strangler Fig pattern moves one module at a time behind an API Gateway, so the old app keeps running and you are never offline.
- Swap in-process calls for gRPC/REST when you need an answer now, and for messaging when the work can wait.
- Give each new service its own database. Never share tables across services.
- Microservices add real costs: network failures, retries, tracing, and spread-out data. Move only when a real pain demands it.
- Best of all, staying a modular monolith is a fine final destination — not just a waiting room.
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.
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.
How to Keep Your Data Boundaries Intact in a Modular Monolith (.NET)
Learn simple, practical ways to keep data boundaries strong in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins.
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.
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.
Understanding Microservices: Core Concepts and Benefits for .NET
A beginner-friendly guide to microservices in .NET: what they are, the core ideas behind them, their real benefits and trade-offs, and when to use them.