What Is a Modular Monolith and Why It Matters
Learn modular monolith architecture: clean module boundaries, internal APIs, shared database with schema isolation, and how it prepares you for microservices without the complexity.
What Is a Modular Monolith?
A modular monolith is a single deployable unit where code is organized into well-isolated modules with clearly defined internal boundaries. It's not a microservice — all modules run in-process — but each module behaves as if it could be extracted later.
The Problem It Solves
Most "big ball of mud" codebases started as monoliths where:
- Any code can call any other code
- Database tables from one domain are queried by many
- A change in one area silently breaks another
A modular monolith prevents this by enforcing explicit module boundaries while keeping deployment simple.
Module Structure
src/
├── Modules/
│ ├── Orders/
│ │ ├── Api/ ← public contract (interfaces, DTOs)
│ │ │ ├── IOrderModule.cs
│ │ │ └── OrderDto.cs
│ │ ├── Application/ ← use cases
│ │ ├── Domain/ ← entities, value objects
│ │ └── Infrastructure/ ← EF Core, repositories
│ ├── Products/
│ │ ├── Api/
│ │ ├── Application/
│ │ └── Infrastructure/
│ └── Users/
│ └── ...
├── Shared/
│ ├── Database/ ← shared DbContext
│ └── Events/ ← internal event bus
└── Host/ ← Program.cs, DI setupModule Contracts
Each module exposes a public interface. Other modules only call through it.
// Modules/Products/Api/IProductModule.cs
public interface IProductModule
{
Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> IsInStockAsync(int productId, int quantity, CancellationToken ct = default);
Task ReserveStockAsync(int productId, int quantity, CancellationToken ct = default);
}
// Modules/Orders/Application/CreateOrderHandler.cs
public class CreateOrderHandler(IProductModule products, IOrderRepository orders)
{
public async Task Handle(CreateOrderCommand cmd)
{
// Call products through the interface — not directly into its internals
if (!await products.IsInStockAsync(cmd.ProductId, cmd.Quantity))
throw new InsufficientStockException();
await products.ReserveStockAsync(cmd.ProductId, cmd.Quantity);
// ...
}
}Database Isolation with Schemas
Instead of separate databases (microservices), use separate schemas.
// Modules/Orders/Infrastructure/OrdersDbContext.cs
public class OrdersDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder model)
{
model.HasDefaultSchema("orders"); // all tables go in orders schema
model.ApplyConfigurationsFromAssembly(GetType().Assembly);
}
}
// Modules/Products/Infrastructure/ProductsDbContext.cs
public class ProductsDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder model)
{
model.HasDefaultSchema("products"); // separate schema
}
}Cross-module queries go through the module's public API, not by joining tables across schemas.
Internal Events (Loose Coupling)
When an order is placed, the inventory module needs to know — but the orders module shouldn't depend on inventory.
// Shared/Events/OrderPlacedEvent.cs
public record OrderPlacedEvent(int OrderId, int CustomerId, List<OrderLineEvent> Lines);
// Orders module — publishes the event
public class CreateOrderHandler(IEventBus events)
{
public async Task Handle(CreateOrderCommand cmd)
{
var order = await CreateOrderAsync(cmd);
await events.PublishAsync(new OrderPlacedEvent(order.Id, cmd.CustomerId, cmd.Lines));
}
}
// Inventory module — subscribes to the event
public class InventoryEventHandler : IEventHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent e)
{
foreach (var line in e.Lines)
await DeductStockAsync(line.ProductId, line.Quantity);
}
}Registration Pattern
// Each module registers itself
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<OrdersDbContext>(options =>
options.UseNpgsql(config.GetConnectionString("Orders")));
services.AddScoped<IOrderModule, OrderModuleService>();
services.AddMediatR(typeof(OrdersModule).Assembly);
return services;
}
}
// Host/Program.cs
builder.Services
.AddOrdersModule(builder.Configuration)
.AddProductsModule(builder.Configuration)
.AddUsersModule(builder.Configuration);Key Takeaways
- A modular monolith enforces explicit boundaries without the operational complexity of microservices
- Modules communicate through public interfaces — never by calling internal implementations directly
- Schema separation in the same database mimics per-service databases without the distributed systems overhead
- Internal events decouple modules without network calls
- This architecture prepares you for microservices — if a module needs to scale independently later, extract it as-is
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.