Skip to main content
SEMastery
Architectureintermediate

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.

12 min readUpdated February 18, 2026

A school bag with proper compartments

Think about your school bag.

A bad bag is one big empty sack. You drop your books, lunch box, water bottle, pens, and gym shoes all into the same space. At first it feels fast. But soon the lunch leaks onto your notebook, your pen pokes a hole, and finding one thing means digging through everything. This is the tangled monolith — one app where all the code is thrown together.

A good bag has separate compartments. One zip pocket for books, one cool pocket for the water bottle, one closed pocket for the lunch box, and a side net for shoes. The bag is still one bag you carry to school every day. But inside, each thing has its own clean space with a wall around it. This is the modular monolith — one app you deploy together, split inside into clean modules.

Now, vertical slice architecture is how you pack each compartment. Instead of sorting by type (all caps in one shelf, all bottles in another shelf, far across the room), you pack by what you actually use together. Your full water-break kit — bottle, napkin, small towel — sits in one pocket. When you want water, you open one pocket and everything is there.

That is the whole idea of this article. One bag (modular monolith), packed by task (vertical slices). Let us build it step by step.

Two ways to cut a cake

Before code, picture a layered cake. There are two ways to cut it.

The old way cuts the cake flat, layer by layer. You get a plate of just sponge, a plate of just cream, a plate of just jam. This is the classic layered (horizontal) style: all your controllers in one folder, all your services in another, all your database code in a third. To add one new feature, you must run around and touch every layer.

The vertical slice way cuts straight down, top to bottom. Each slice has a bit of sponge, cream, and jam together. This is vertical slice architecture: each feature keeps its request, its logic, its validation, and its data access in one folder. To add a feature, you add one slice. To change it, you touch one slice.

Horizontal layers cut the app sideways. Vertical slices cut top to bottom, by feature.

The key insight: code that changes together should live together. A feature is a thing that changes together. So we group by feature.

The big picture: bag, then compartments

A modular monolith and vertical slices work at two different sizes.

  • Modular monolith is the big picture. It splits the whole app into a few large modules, like Orders, Catalog, and Shipping. Each module has a strong wall. Modules talk to each other only through a public door (a public contract), never by reaching inside.
  • Vertical slices are the small picture. Inside one module, you split the work into many tiny features, each in its own slice.

So the bag has compartments (modules), and each compartment is packed by task (slices).

One deployable app holds several modules. Each module is full of small vertical slices.

Why teams pick this combo

Here is a quick comparison of the three common ways to build an app, so you can see where this combo sits.

StyleDeployInner wallsBest for
Tangled monolithOne unitNoneTiny throwaway apps
Modular monolith + slicesOne unitStrongMost teams, most apps
MicroservicesMany unitsStrongHuge scale, many teams

And here is why the slice part specifically helps day to day.

QuestionLayered appVertical slices
Where is the "Create Order" code?Spread across 4 foldersAll in one folder
What breaks if I change it?Hard to knowJust that one slice
Can a new dev follow it?Needs the whole mapRead one slice
How do I test it?Mock many layersTest one focused unit

The folder shape

Let us make this real. Here is how the project looks on disk. Notice the modules at the top, and the feature slices inside each module.

Folder layout of a modular monolith with slices

Solution
Modules
Orders Module
Features
Create Order Slice

Steps

1

Solution

The one app you deploy

2

Modules folder

Orders, Catalog, Shipping

3

Orders module

Owns its own database tables

4

Features folder

One folder per use case

5

CreateOrder slice

Request, handler, validator, endpoint

Modules are top-level folders. Each feature is its own slice folder holding everything it needs.

In text form, it looks like this:

src/
  Modules/
    Orders/
      Features/
        CreateOrder/
          CreateOrder.cs        // request + handler + validator
          CreateOrderEndpoint.cs
        GetOrder/
          GetOrder.cs
      OrdersDbContext.cs
      Contracts/                // the public door other modules use
        IOrdersApi.cs
    Catalog/
      Features/
        ...
  Api/
    Program.cs                  // wires all modules together

Each slice folder is a small, complete story. You do not jump around to understand it.

A vertical slice in C#

Now the fun part. Let us write the Create Order slice. Everything for this one feature lives together: the request, the handler that does the work, and the validation rule. We do not use MediatR here, because MediatR is now a commercial product and needs a paid license for many teams. A plain handler class works great and stays free.

namespace Orders.Features.CreateOrder;
 
// The request: what the caller sends in.
public record CreateOrderRequest(Guid CustomerId, string Product, int Quantity);
 
// The result: what we send back.
public record CreateOrderResult(Guid OrderId);
 
// The handler: the actual work for this one feature.
public sealed class CreateOrderHandler
{
    private readonly OrdersDbContext _db;
 
    public CreateOrderHandler(OrdersDbContext db) => _db = db;
 
    public async Task<CreateOrderResult> Handle(
        CreateOrderRequest request,
        CancellationToken ct)
    {
        // Simple rule lives right here in the slice.
        if (request.Quantity <= 0)
            throw new ArgumentException("Quantity must be at least 1.");
 
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Product = request.Product,
            Quantity = request.Quantity,
            CreatedAt = DateTimeOffset.UtcNow
        };
 
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
 
        return new CreateOrderResult(order.Id);
    }
}

Notice how the rule, the data access, and the shape of the request all sit side by side. A new teammate can read this one file and understand the whole feature. That is the win.

Now we expose it with a minimal API endpoint, again kept inside the same slice folder.

namespace Orders.Features.CreateOrder;
 
public static class CreateOrderEndpoint
{
    public static void MapCreateOrder(this IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (
            CreateOrderRequest request,
            CreateOrderHandler handler,
            CancellationToken ct) =>
        {
            var result = await handler.Handle(request, ct);
            return Results.Created($"/orders/{result.OrderId}", result);
        });
    }
}

The HTTP route and the logic stay in the same place. When you change this feature, you open one folder and you are done.

How a request flows through one slice

Here is the journey of a single web request as it passes through the slice. It is short and straight, which is exactly the point.

A request enters the endpoint, runs the handler, saves to the module database, and returns.

The most important rule: walls between modules

This is where many teams slip. The whole value of a modular monolith comes from strong walls between modules. So we follow two firm rules.

  1. A module never touches another module's database tables directly. Orders cannot run a SQL query against Catalog's tables. If Orders needs product info, it must ask Catalog politely.
  2. Modules talk only through a public contract. Each module exposes a small public interface, its "front door." Everything else inside the module is private.

Module talking to another module the right way

Orders slice
Catalog contract
Catalog handler
Catalog DB

Steps

1

Orders slice

Needs a product price

2

ICatalogApi

The public front door

3

Catalog handler

Runs Catalog's own logic

4

Catalog DB

Private, only Catalog touches it

Orders asks Catalog through its public contract. It never reaches into Catalog's private database.

In code, the contract is just a small interface that lives in a shared "Contracts" spot. Other modules depend on the interface, not on the inner classes.

// Public door, shared so other modules can use it.
namespace Catalog.Contracts;
 
public interface ICatalogApi
{
    Task<decimal?> GetPriceAsync(Guid productId, CancellationToken ct);
}
 
// Hidden inside Catalog: the real work.
namespace Catalog.Features.Pricing;
 
internal sealed class CatalogApi : ICatalogApi
{
    private readonly CatalogDbContext _db;
 
    public CatalogApi(CatalogDbContext db) => _db = db;
 
    public async Task<decimal?> GetPriceAsync(Guid productId, CancellationToken ct)
    {
        var product = await _db.Products.FindAsync([productId], ct);
        return product?.Price;
    }
}

The internal keyword is your friend here. It keeps the real class private to the Catalog module, so other modules are forced to use the public ICatalogApi door. That single keyword protects your walls.

Wiring modules together in one app

Even though modules are separate, they still live in one running app. Each module offers a small setup method that registers its own services. The main Program.cs just calls each one. This keeps the startup file clean and lets each module own its own wiring.

// In Program.cs
var builder = WebApplication.CreateBuilder(args);
 
builder.Services
    .AddOrdersModule(builder.Configuration)
    .AddCatalogModule(builder.Configuration)
    .AddShippingModule(builder.Configuration);
 
var app = builder.Build();
 
app.MapCreateOrder();   // endpoints from the Orders slice
// ... map other module endpoints
 
app.Run();

Each AddXModule method registers that module's handlers, its DbContext, and its public contract. The big app does not know the private details. It only knows the front doors.

How modules talk: direct calls and events

Modules can talk in two healthy ways. Pick the one that fits.

WayWhat it isGood when
Direct callOrders calls ICatalogApi and waits for the answerYou need the answer right now (like a price)
Domain eventOrders raises "OrderCreated", others react laterOther modules should react, but Orders should not wait

A direct call is simple and immediate. An event keeps modules loosely tied: when an order is created, Orders just shouts "OrderCreated!" and walks away. Shipping hears it and prepares a parcel. Notifications hears it and sends an email. Orders does not know or care who is listening. This keeps the wall strong, because Orders has no link to Shipping at all.

An OrderCreated event lets other modules react on their own without Orders knowing them.

A small note on tools. You may have heard of MediatR and MassTransit for messages and events. As of 2026, both moved to a commercial license, so they cost money for many teams. The good news: vertical slices and modular monoliths do not depend on them. You can raise and handle events with a tiny bit of your own code, or with a free library, and the pattern stays exactly the same.

When to grow into microservices

A big strength of this design is that it grows with you. Because your modules already have clean walls and talk only through public doors, you can later lift one module out into its own microservice with far less pain. You do this only when you hit a real reason, such as one module needing to scale on its own, or two teams needing to deploy separately. Until then, one app is simpler, cheaper, and faster to ship. Start as a modular monolith. Split later, if ever.

A few friendly tips

  • Right-size your slices. Do not make a slice per tiny endpoint if many endpoints belong together. Group by real use case.
  • Keep it simple inside a slice. A simple read can go straight to the database. You do not need extra layers when the feature is small.
  • Use clean architecture only where it earns its keep. For a rich, complex slice, you can add proper layers inside that one slice. For a simple one, do not.
  • Guard your walls with internal. Make everything internal by default and open only the public contract.
  • Each module owns its own data. No shared tables across modules.

Quick recap

  • A modular monolith is one app you deploy together, split inside into modules with strong walls, like a school bag with proper compartments.
  • Vertical slice architecture packs each feature in one folder: request, handler, validation, and data access together, instead of spreading code across layers.
  • The two work at different sizes: modules are the big compartments, slices are how you pack each one.
  • Modules must talk only through public contracts, never by touching another module's database. The internal keyword helps protect this.
  • You can build slices without MediatR or MassTransit, which now need paid licenses. Plain handler classes work great.
  • Start here for most apps. You can split a module into a microservice later only when you truly need to.

References and further reading

Related Posts