Modular Monolith Architecture cover image

Modular Monolith Architecture

By Julian Krause · Sunday, March 1, 2026 · ~3 min read

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)

Elena Wednesday, March 11, 2026 5:08 PM

I tried this approach and it works perfectly!

Greta Saturday, March 7, 2026 8:08 PM

I had the same experience, can confirm.

Felix Thursday, March 12, 2026 5:08 PM

Well written and easy to follow. Keep it up!

Leave a Comment