Modular Monolith — Microservices Benefits Without the Distributed Systems Tax
Build a single deployable .NET app with hard module boundaries, in-process event communication, and per-module schemas — ready to extract into microservices when you actually need to.
Why Not Microservices From Day One?
Microservices trade deployment independence for significant operational complexity. You pay upfront:
- Network latency on every cross-service call
- Distributed transactions (saga pattern, eventual consistency)
- Per-service CI/CD pipelines, health checks, service discovery
- Distributed tracing across services to debug a single request
- Integration testing becomes a multi-service orchestration problem
Most teams don't need this — yet. The modular monolith gives you the bounded context separation of microservices inside a single deployable binary.
What a Modular Monolith Looks Like
One application, multiple self-contained modules. Each module owns its domain, its data access, and its public API. Modules communicate through well-defined interfaces — not direct class references.
src/
Modules/
Orders/
OrdersModule.cs ← module registration
Domain/
Order.cs
OrderStatus.cs
Application/
PlaceOrder/
PlaceOrderCommand.cs
PlaceOrderHandler.cs
Infrastructure/
OrdersDbContext.cs
OrderRepository.cs
Contracts/
IOrderService.cs ← public interface for other modules
OrderPlacedEvent.cs ← integration event
Inventory/
InventoryModule.cs
Domain/
Product.cs
StockLevel.cs
Application/
ReserveStock/
ReserveStockCommand.cs
ReserveStockHandler.cs
Infrastructure/
InventoryDbContext.cs
Contracts/
IInventoryService.cs
StockReservedEvent.cs
Notifications/
NotificationsModule.cs
...
Shared/
Events/
IIntegrationEvent.cs
IEventPublisher.cs
Persistence/
SharedDbContext.cs ← optional — only if modules share a DB
Program.csEnforcing Module Boundaries With internal
The most powerful tool here is C#'s internal access modifier. Everything inside a module that isn't meant to be called from outside is internal:
// Orders/Infrastructure/OrderRepository.cs
// internal — no other module can reference this directly
internal class OrderRepository : IOrderRepository
{
private readonly OrdersDbContext _db;
public OrderRepository(OrdersDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _db.Orders.FindAsync(new object[] { id }, ct);
}
// Orders/Domain/Order.cs — also internal to the module
internal class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public List<OrderLine> Lines { get; private set; } = new();
// ... domain methods
}Only the Contracts/ folder is public:
// Orders/Contracts/IOrderService.cs — public interface
public interface IOrderService
{
Task<OrderSummary> PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct = default);
Task<OrderDetails?> GetOrderAsync(Guid orderId, CancellationToken ct = default);
}
// Orders/Contracts/OrderPlacedEvent.cs — public integration event
public record OrderPlacedEvent(
Guid OrderId,
Guid CustomerId,
decimal Total,
DateTime PlacedAt) : IIntegrationEvent;Module Registration
Each module registers its own services:
// Orders/OrdersModule.cs
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<OrdersDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Orders")));
// internal implementations registered against public interfaces
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IOrderService, OrderService>();
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<OrdersModule>());
return services;
}
}// Program.cs
builder.Services
.AddOrdersModule(builder.Configuration)
.AddInventoryModule(builder.Configuration)
.AddNotificationsModule(builder.Configuration);Cross-Module Communication: Interfaces and In-Process Events
Option 1: Interface injection — one module depends on another's public contract:
// InventoryModule — needs to know about Orders to reserve stock
// It takes a dependency on the Orders contract, not the implementation
internal class ReserveStockHandler : IRequestHandler<ReserveStockCommand>
{
private readonly IInventoryRepository _inventory;
public ReserveStockHandler(IInventoryRepository inventory)
=> _inventory = inventory;
public async Task Handle(ReserveStockCommand request, CancellationToken ct)
{
await _inventory.ReserveAsync(request.ProductId, request.Quantity, ct);
}
}Option 2: In-process events — looser coupling, no direct dependency:
// Shared event publisher interface
public interface IEventPublisher
{
Task PublishAsync<T>(T @event, CancellationToken ct = default) where T : IIntegrationEvent;
}
// Simple in-process publisher using MediatR notifications
public class InProcessEventPublisher : IEventPublisher
{
private readonly IMediator _mediator;
public InProcessEventPublisher(IMediator mediator) => _mediator = mediator;
public Task PublishAsync<T>(T @event, CancellationToken ct = default) where T : IIntegrationEvent
=> _mediator.Publish(@event, ct);
}Orders publishes:
// Inside PlaceOrderHandler
await _eventPublisher.PublishAsync(new OrderPlacedEvent(
order.Id, order.CustomerId, order.Total, order.CreatedAt), ct);Inventory listens without knowing about Orders:
// Inventory/Application/OrderPlacedHandler.cs
internal class OrderPlacedHandler : INotificationHandler<OrderPlacedEvent>
{
private readonly IInventoryRepository _inventory;
public OrderPlacedHandler(IInventoryRepository inventory) => _inventory = inventory;
public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
{
// Reserve stock when an order is placed
foreach (var line in notification.Lines)
await _inventory.ReserveAsync(line.ProductId, line.Quantity, ct);
}
}Shared Database vs Per-Module Schema
Two valid approaches — each with trade-offs:
Per-module schema (recommended for clean boundaries):
-- Orders module owns this schema
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_lines (...);
-- Inventory module owns this schema
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);// OrdersDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}Modules cannot join across schemas — they must use events or contract interfaces. This boundary is enforced at the database level.
Shared database, shared schema — simpler to start, but modules can start reaching into each other's tables. Acceptable for early stages, a liability as the team grows.
Migrating to Microservices Later
When a module is ready to extract:
- Its
DbContextalready maps to its own schema — extract it to a new database with minimal migration effort - Its
IIntegrationEvents already define the async contract — swap the in-process publisher for a message broker (MassTransit, Azure Service Bus) - Its public
IModuleServiceinterface becomes an HTTP or gRPC contract - Deploy the extracted module as a separate service, update the caller to use the network version
The modular monolith is the prep work. Done right, extraction is refactoring — not a rewrite.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.