The Modular Monolith in .NET: Best of Both Worlds
Microservices solve the problem of scaling teams and deploying independently. They also introduce distributed computing complexity, network latency, eventual consistency, and operational overhead. For most teams, a modular monolith gives you the architectural boundaries you need without the distributed systems tax.
What Makes It Modular?
A modular monolith is a single deployable application where the code is organised into independent modules with explicit boundaries. Each module owns its data, exposes a defined public API, and communicates with other modules through contracts — not by reaching into each other's internals.
Solution Structure
MyApp.sln
├── src/
│ ├── MyApp.Host/ (ASP.NET Core host - composition root)
│ ├── Modules/
│ │ ├── Orders/
│ │ │ ├── MyApp.Modules.Orders/ (public contracts)
│ │ │ └── MyApp.Modules.Orders.Core/ (internal implementation)
│ │ ├── Payments/
│ │ │ ├── MyApp.Modules.Payments/
│ │ │ └── MyApp.Modules.Payments.Core/
│ │ └── Inventory/
│ │ ├── MyApp.Modules.Inventory/
│ │ └── MyApp.Modules.Inventory.Core/
│ └── MyApp.Shared/ (cross-cutting: auth, logging, etc.)
Each module has two projects. The contracts project contains interfaces, DTOs, and integration events that other modules can reference. The core project contains the implementation and is referenced only by the host.
Module Contracts
A module exposes a service interface that other modules can call:
// MyApp.Modules.Payments (contracts project)
namespace MyApp.Modules.Payments;
public interface IPaymentService
{
Task<PaymentResult> ProcessPaymentAsync(
Guid orderId, decimal amount, CancellationToken ct = default);
}
public record PaymentResult(bool Success, string? TransactionId, string? Error);
The implementation is internal to the module:
// MyApp.Modules.Payments.Core (internal project)
namespace MyApp.Modules.Payments.Core;
internal class PaymentService : IPaymentService
{
private readonly PaymentsDbContext _db;
public PaymentService(PaymentsDbContext db) => _db = db;
public async Task<PaymentResult> ProcessPaymentAsync(
Guid orderId, decimal amount, CancellationToken ct)
{
// Process payment, save to module's own tables
var payment = new Payment(orderId, amount);
_db.Payments.Add(payment);
await _db.SaveChangesAsync(ct);
return new PaymentResult(true, payment.TransactionId, null);
}
}
Separate Data Ownership
Each module has its own DbContext with its own tables. Modules must not query each other's tables directly.
internal class OrdersDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
}
}
// File: PaymentsDbContext.cs
internal class PaymentsDbContext : DbContext
{
public DbSet<Payment> Payments => Set<Payment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("payments");
}
}
Using separate schemas (or even separate databases) enforces data boundaries at the storage level.
Module Registration
Each module provides an extension method to register its services:
public static class OrdersModuleExtensions
{
public static IServiceCollection AddOrdersModule(
this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("Orders")));
services.AddScoped<IOrderService, OrderService>();
return services;
}
public static IEndpointRouteBuilder MapOrdersEndpoints(
this IEndpointRouteBuilder app)
{
app.MapPost("/api/orders", OrderEndpoints.Create);
app.MapGet("/api/orders/{id:guid}", OrderEndpoints.GetById);
return app;
}
}
The host wires everything together:
builder.Services.AddOrdersModule(builder.Configuration);
builder.Services.AddPaymentsModule(builder.Configuration);
builder.Services.AddInventoryModule(builder.Configuration);
var app = builder.Build();
app.MapOrdersEndpoints();
app.MapPaymentsEndpoints();
app.MapInventoryEndpoints();
Inter-Module Communication
Modules communicate in two ways:
Synchronous — through the contract interfaces (e.g., the Orders module calls IPaymentService). Use this for operations that need an immediate response.
Asynchronous — through integration events using an in-process mediator or message bus. Use this for notifications where the caller doesn't need a response.
// Integration event defined in the contracts project
public record OrderPlacedEvent(Guid OrderId, decimal Total);
// Handler in another module
internal class OrderPlacedHandler : INotificationHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
{
// Inventory module reserves stock
}
}
The Path to Microservices
The beauty of a modular monolith is that extracting a module into a microservice is straightforward. The module already has defined boundaries, its own data, and a public contract. You replace the in-process calls with HTTP or messaging and deploy it separately.
Start with a modular monolith. Extract to microservices only when you have a concrete reason — independent scaling, different technology needs, or separate team ownership. Most applications never need to make that move.