Modular Monolith Architecture

Modular Monolith Architecture

Julian Krause · March 01, 2026
ArchitectureDDDClean Architecture
ArchitectureEngineering Culture

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 Mar 11, 2026 · 17:08

I tried this approach and it works perfectly!

Greta Mar 07, 2026 · 20:08

I had the same experience, can confirm.

Felix Mar 12, 2026 · 17:08

Well written and easy to follow. Keep it up!


Leave a comment

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.