The modular monolith is gaining traction as teams realize that microservices solve organizational problems, not technical ones. If your team is small enough to coordinate on a single codebase, a well-structured monolith gives you clean module boundaries, independent deployability readiness, and none of the operational overhead of distributed systems.
Module Structure
Each module is a self-contained vertical slice of the application with its own domain, data access, and API surface. Modules communicate through well-defined contracts, never through shared database tables or direct class references.
/src/
├── Postnomic.Host/ # Composition root
├── Modules/
│ ├── Orders/
│ │ ├── Orders.Api/ # Controllers, DTOs
│ │ ├── Orders.Domain/ # Entities, value objects
│ │ ├── Orders.Infrastructure/ # EF Core, repositories
│ │ └── Orders.Contracts/ # Public interfaces and events
│ ├── Inventory/
│ │ ├── Inventory.Api/
│ │ ├── Inventory.Domain/
│ │ ├── Inventory.Infrastructure/
│ │ └── Inventory.Contracts/
│ └── Shipping/
│ ├── Shipping.Api/
│ ├── Shipping.Domain/
│ ├── Shipping.Infrastructure/
│ └── Shipping.Contracts/
Enforcing Module Boundaries
The most critical aspect of a modular monolith is boundary enforcement. Without it, modules will gradually couple until you have a traditional monolith with folder organization. We enforce boundaries at compile time using project references and at test time using architecture tests.
// Architecture test using NetArchTest
[Fact]
public void OrdersModule_ShouldNotReference_InventoryDomain()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOn("Inventory.Domain")
.GetResult();
result.IsSuccessful.Should().BeTrue(
"Orders module must not directly reference Inventory domain. " +
"Use Inventory.Contracts instead.");
}
[Fact]
public void Modules_ShouldOnlyCommunicate_ThroughContracts()
{
var moduleAssemblies = new[]
{
typeof(Order).Assembly,
typeof(InventoryItem).Assembly,
typeof(Shipment).Assembly
};
foreach (var assembly in moduleAssemblies)
{
var otherDomains = moduleAssemblies
.Where(a => a != assembly)
.Select(a => a.GetName().Name!)
.ToArray();
var result = Types.InAssembly(assembly)
.ShouldNot()
.HaveDependencyOnAny(otherDomains)
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
}
Inter-Module Communication
Modules communicate through in-process events and query interfaces defined in Contracts projects. This keeps modules decoupled while avoiding network overhead.
// Orders.Contracts — the public API of the Orders module
public interface IOrderQueryService
{
Task<OrderSummary?> GetOrderSummaryAsync(Guid orderId, CancellationToken ct);
}
public record OrderConfirmedEvent(Guid OrderId, Guid CustomerId, decimal Total);
// Inventory module consumes the contract
public class WhenOrderConfirmed_ReserveStock(InventoryDbContext db)
: IEventHandler<OrderConfirmedEvent>
{
public async Task Handle(OrderConfirmedEvent @event, CancellationToken ct)
{
// React to order confirmation without depending on Orders.Domain
await ReserveStockForOrderAsync(@event.OrderId, ct);
}
}
Separate DbContexts per Module
Each module owns its database schema through a dedicated DbContext. While they share the same physical database, each context only maps the tables belonging to its module. This prevents accidental cross-module queries and makes future extraction to a separate database straightforward.
public class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders"); // Schema isolation
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}
}
The modular monolith is not a stepping stone to microservices — it is a valid architecture in its own right. Many teams will never need to extract modules into separate services. But if you do, the clean boundaries make that extraction a mechanical process rather than a painful untangling.
Comments (2)
I tried this approach and it works perfectly!
I had the same experience, can confirm.
Well written and easy to follow. Keep it up!
Leave a Comment