Cart
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.
A module is a slice of the system that owns its data, logic, and API. Think “Orders”, “Billing”, “Identity”—each with:
If two modules must talk, they do it by:
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.
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();
// 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.
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.
Sometimes Billing needs to read from Orders. Options:
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.
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.
Classic monolith
Modular monolith
Microservices
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.
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.
*.Contracts
, flip the direction, or use events.BuildingBlocks
tiny (events, error type, nothing business-specific).*.Application
and keep endpoints thin.internal
, expose just what other modules must call.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.
internal
by default; expose a small public API.
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© 2025 Dometrain. All rights reserved.