Our Summer Sale is Live! 🎉
Everything 30% off with code SUMMER30! (Excl. Team and VS Pro)
00
Days
00
Hrs
00
Min
00
Sec
Get 30% off anything!

Getting Started with Modular Monoliths in .NET

17/08/2025

You want clear boundaries and independent work streams, but you don’t want to run a zoo of services, queues, and distributed tracing. A modular monolith gives you both: one deployable app with strict, enforceable modules that map to business areas and move on their own cadence.

This is a short, code-first walkthrough for .NET (latest), Minimal APIs, and EF Core—plus how it compares to a classic monolith and to microservices.


What a “module” actually is

A module is a slice of the system that owns its data, logic, and API. Think “Orders”, “Billing”, “Identity”—each with:

  • its own project (assembly),
  • internal types by default,
  • a narrow public surface (contracts + endpoints),
  • no direct calls into other modules’ internals,
  • its own DbContext (and ideally its own schema).

If two modules must talk, they do it by:

  • events (raise an in-process event and let other modules handle it), or
  • queries/commands through contracts (interfaces or DTOs placed in a shared contracts project).

Solution layout example

src/
  Web/                            # Minimal API host
  BuildingBlocks/                 # Cross-cutting (events, result types)
modules/
  Identity/
    Identity.API/                 # endpoints for Identity
    Identity.Application/
    Identity.Infrastructure/
    Identity.Contracts/           # DTOs/interfaces other modules may use
  Orders/
    Orders.API/
    Orders.Application/
    Orders.Infrastructure/
    Orders.Contracts/
  Billing/
    Billing.API/
    Billing.Application/
    Billing.Infrastructure/
    Billing.Contracts/
  • *.API exposes endpoints via Minimal APIs.
  • *.Application holds use cases/handlers.
  • *.Infrastructure holds EF Core, repositories, migrations.
  • *.Contracts contains public DTOs/events—the only thing other modules can reference.

Keep default accessibility internal and only expose what the outside must see.


Minimal API endpoints per module

Each module registers its endpoints through a single method so the host never sees internals.

// modules/Orders/Orders.API/OrdersEndpoints.cs
using Microsoft.AspNetCore.Http.HttpResults;
using Orders.Application;
using Orders.Contracts;

namespace Orders.API;

public static class OrdersEndpoints
{
    public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/orders");

        group.MapPost("/", async (CreateOrderRequest req, CreateOrderHandler handler) =>
        {
            var id = await handler.Handle(req);
            return TypedResults.Created($"/orders/{id}", new { id });
        });

        group.MapGet("/{id:guid}", async (Guid id, GetOrderHandler handler) =>
        {
            var dto = await handler.Handle(id);
            return dto is null ? TypedResults.NotFound() : TypedResults.Ok(dto);
        });

        return app;
    }
}

Host wires it up without touching internals:

// src/Web/Program.cs
using Orders.API;
using Orders.Infrastructure;
using Orders.Application;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddOrdersInfrastructure(builder.Configuration)
    .AddOrdersApplication();

var app = builder.Build();

app.MapOrders();           // <- one line per module
// app.MapIdentity();
// app.MapBilling();

app.Run();

EF Core: one DbContext per module, schema per module

// modules/Orders/Orders.Infrastructure/OrdersDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace Orders.Infrastructure;

public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.HasDefaultSchema("orders");

        b.Entity<Order>(cfg =>
        {
            cfg.ToTable("orders");
            cfg.HasKey(x => x.Id);
            cfg.Property(x => x.CustomerId).IsRequired();
            cfg.OwnsMany(x => x.Lines, lines =>
            {
                lines.ToTable("order_lines");
                lines.WithOwner().HasForeignKey("OrderId");
                lines.Property<int>("Id");    // EF key for owned type
                lines.HasKey("Id");
            });
        });
    }
}

public sealed class Order
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public Guid CustomerId { get; private set; }
    public DateTime CreatedAtUtc { get; private set; } = DateTime.UtcNow;
    public List<OrderLine> Lines { get; } = [];

    public static Order Create(Guid customerId, IEnumerable<(Guid productId, int qty, decimal price)> items)
    {
        var o = new Order { CustomerId = customerId };
        foreach (var (productId, qty, price) in items)
            o.Lines.Add(new OrderLine(productId, qty, price));
        return o;
    }
}

public sealed record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);

This keeps migrations small and focused. Billing can’t “just query” Orders tables without going through a contract or a read model you publish.


Inter-module events (in-process)

You don’t need a broker to keep modules decoupled. Start with a tiny in-process dispatcher.

// src/BuildingBlocks/Events.cs
public interface IEvent { }

public interface IEventHandler<in TEvent> where TEvent : IEvent
{
    Task Handle(TEvent @event, CancellationToken ct = default);
}

public interface IEventBus
{
    Task Publish<T>(T @event, CancellationToken ct = default) where T : IEvent;
}

public sealed class InProcessEventBus(IServiceProvider sp) : IEventBus
{
    public async Task Publish<T>(T @event, CancellationToken ct = default) where T : IEvent
    {
        var handlers = sp.GetServices<IEventHandler<T>>();
        foreach (var h in handlers)
            await h.Handle(@event, ct);
    }
}

Orders raises an event; Billing listens:

// modules/Orders/Orders.Contracts/OrderPlaced.cs
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total) : IEvent;

// modules/Orders/Orders.Application/CreateOrderHandler.cs
public sealed class CreateOrderHandler(OrdersDbContext db, IEventBus bus)
{
    public async Task<Guid> Handle(CreateOrderRequest req)
    {
        var order = Order.Create(req.CustomerId, req.Lines.Select(l => (l.ProductId, l.Quantity, l.UnitPrice)));
        db.Add(order);
        await db.SaveChangesAsync();

        var total = order.Lines.Sum(l => l.UnitPrice * l.Quantity);
        await bus.Publish(new OrderPlaced(order.Id, order.CustomerId, total));
        return order.Id;
    }
}
// modules/Billing/Billing.Application/InvoiceOnOrderPlaced.cs
using Billing.Infrastructure;
using Orders.Contracts;

public sealed class InvoiceOnOrderPlaced(BillingDbContext db) : IEventHandler<OrderPlaced>
{
    public async Task Handle(OrderPlaced e, CancellationToken ct = default)
    {
        db.Add(Invoice.Create(e.OrderId, e.CustomerId, e.Total));
        await db.SaveChangesAsync(ct);
    }
}

Wire the bus and handlers:

// src/Web/Program.cs (continued)
builder.Services.AddSingleton<IEventBus, InProcessEventBus>();
builder.Services.Scan(scan => scan
    .FromApplicationDependencies()
    .AddClasses(c => c.AssignableTo(typeof(IEventHandler<>)))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

If you need real async processing later, swap the bus with a message broker adapter and keep handlers unchanged.


Cross-module queries without tight coupling

Sometimes Billing needs to read from Orders. Options:

  1. Read-only contract: expose a query service in Orders.Contracts and implement it inside Orders.

    // Orders.Contracts
    public interface IOrderReadApi
    {
        Task<OrderDto?> GetById(Guid id);
    }
    public sealed record OrderDto(Guid Id, Guid CustomerId, decimal Total);
    
    // Orders.Application (implementation, internal)
    internal sealed class OrderReadApi(OrdersDbContext db) : IOrderReadApi
    {
        public Task<OrderDto?> GetById(Guid id) =>
            db.Orders
              .Select(o => new OrderDto(o.Id, o.CustomerId, o.Lines.Sum(l => l.UnitPrice * l.Quantity)))
              .FirstOrDefaultAsync(o => o.Id == id);
    }
    

    Billing depends on Orders.Contracts only.

  2. Replicated read model: Orders publishes events; Billing stores what it needs. This avoids cross-module queries on hot paths.

Pick one. Don’t allow random DbContext access across modules.


How it compares

Classic monolith

  • One project, one database, everyone touches everything.
  • Fast to start, hard to keep clean as it grows.
  • Refactors tend to ripple across the whole codebase.

Modular monolith

  • One deployable, many clearly separated modules.
  • Local reasoning, smaller migrations, focused tests.
  • Move fast without network hops and service coordination.

Microservices

  • Deployment independence and fine-grained scaling.
  • Also: versioned contracts, message formats, distributed transactions, network failures, tracing.
  • Worth it at certain scale or org structure—but not the only way to get boundaries.

A modular monolith is a great starting point and an easier migration path: extract a hot module later if it truly needs to stand alone.


Testing the seams (Shouldly)

Write unit tests inside each module. Handlers are plain classes, so they’re easy to test.

// modules/Orders/Orders.Tests/CreateOrderTests.cs
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Orders.Application;
using Orders.Contracts;
using Orders.Infrastructure;
using Shouldly;

public class CreateOrderTests
{
    [Fact]
    public async Task Publishes_OrderPlaced_with_total()
    {
        var options = new DbContextOptionsBuilder<OrdersDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        await using var db = new OrdersDbContext(options);
        var bus = Substitute.For<IEventBus>();

        var handler = new CreateOrderHandler(db, bus);

        var id = await handler.Handle(new CreateOrderRequest(
            Guid.NewGuid(),
            new []
            {
                new OrderLineRequest(Guid.NewGuid(), 2, 10m),
                new OrderLineRequest(Guid.NewGuid(), 1, 5m)
            }));

        id.ShouldNotBe(Guid.Empty);
        await bus.Received(1).Publish(Arg.Is<OrderPlaced>(e => e.Total == 25m));
    }
}

No web server needed, no HTTP plumbing. Fast feedback.


Common mistakes and quick fixes

  • Cyclic references between modules → move contracts to *.Contracts, flip the direction, or use events.
  • Fat shared project full of helpers → that’s a hidden dependency magnet. Keep BuildingBlocks tiny (events, error type, nothing business-specific).
  • Reaching into another schema with EF → stop; add a contract or replicate a read model.
  • Endpoints doing business logic → move logic to *.Application and keep endpoints thin.
  • Everything public → default to internal, expose just what other modules must call.

When to extract a module

You don’t need microservices on day one. Consider extraction only if one module has very different scaling needs, release cadence, or tech requirements. If you’ve kept contracts clean and data isolated, extraction is mostly plumbing.


A short checklist

  • [ ] Separate assemblies per module: API, Application, Infrastructure, Contracts.
  • [ ] internal by default; expose a small public API.
  • [ ] One DbContext per module; map to its own schema.
  • [ ] Interactions go through events or contracts, not direct data access.
  • [ ] Endpoints are thin; logic sits in handlers.
  • [ ] Tests live next to each module.
  • [ ] The host composes modules; it doesn’t know internals.

About the Author

author_img

Nick Chapsas

Nick Chapsas is a .NET & C# content creator, educator and a Microsoft MVP for Developer Technologies with years of experience in Software Engineering and Engineering Management.

He has worked for some of the biggest companies in the world, building systems that served millions of users and tens of thousands of requests per second.

Nick creates free content on YouTube and is the host of the Keep Coding Podcast.

More courses by Nick Chapsas