Skip to main content
SEMastery
Architectureintermediate

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.

15 min readUpdated April 25, 2026

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

Tangled Monolith
Modular Monolith
Microservices

Steps

1

Tangled Monolith

One app, no clear walls, code mixed together

2

Modular Monolith

One app, strong module walls, clean contracts

3

Microservices

Many apps, each its own service over the network

A tangled monolith has no inner walls. A modular monolith is one app with strong inner walls. Microservices are many separate apps 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.

In a tangled monolith every part can reach into every other part, so there are no clean lines to cut along.

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.
In a modular monolith, modules talk only through public contracts, so each module is already a clean shape that can be lifted out.

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

Add a Gateway
Pick One Module
Build a Service
Reroute Traffic
Repeat

Steps

1

Add a Gateway

Route all traffic through one front door

2

Pick One Module

Choose a module with a clean boundary

3

Build a Service

Move that module into its own service

4

Reroute Traffic

Point its routes to the new service

5

Repeat

Do the next module, keep the rest running

Put a router in front. Move one module out at a time. The monolith shrinks while the services grow, with no big-bang switch.

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.

The gateway routes most traffic to the monolith but sends one moved feature to its new service.

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.

Each service keeps its own database. Services never share tables, only contracts and messages.

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.

ConcernTangled MonolithModular MonolithMicroservices
Inner boundariesNoneStrongStrong
How to run itOne appOne appMany apps
Operational costLowLowHigh
Independent scalingNoNoYes
Easy to split laterVery hardEasyAlready split
Team independenceLowMediumHigh
Risk of failure spreadingHighMediumLower (isolated)

And here is a small guide for choosing which call style to use once modules become services.

NeedBest choiceWhy
Instant request and replygRPC or RESTCaller needs the answer now
Fire and forget eventMessaging (queue)Receiver can handle it later, stays loosely coupled
Heavy read trafficREST plus a cacheSimple, cache-friendly, easy to scale reads
Internal high-speed callsgRPCFast, 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.

A decision flow: only move a module out when there is a real, specific reason. Otherwise stay a modular monolith.

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:

  1. Notifications already owns its own tables and only listens to events like OrderPlaced. Good — the wall is clean.
  2. Build a small Notifications service. It subscribes to the same OrderPlaced message from the broker.
  3. In the monolith, stop handling notifications in-process. The event now flows to the new service through the queue.
  4. 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

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