Modular Monolith

Intro

A modular monolith is a single deployable application that is intentionally split into strict modules with explicit boundaries. It matters because you get most of the practical benefits people want from Microservices - clear ownership, clean contracts, and safer parallel development - without paying the full distributed systems tax on day one. Reach for it when your product is growing, domain boundaries are becoming clear, and your team does not want the operational overhead of many services yet. For most product teams, especially on .NET with Aspire, this is the pragmatic default: evolve architecture quality first, then distribute only where pressure proves it is worth it.

Mechanism

Each module owns its own domain model, use cases, persistence rules, and public contract.

flowchart LR
    Host[Single deployment] --> Orders[Orders module]
    Host --> Inventory[Inventory module]
    Host --> Billing[Billing module]

    Orders --> OrdersData[Orders schema]
    Inventory --> InventoryData[Inventory schema]
    Billing --> BillingData[Billing schema]

    Orders -- contract api --> Inventory
    Orders -- order placed event --> Billing
    Inventory -- stock reserved event --> Orders

.NET Implementation

src/
  Modules/
    Orders/
      Orders.Contracts/
      Orders.Core/
      Orders.Infrastructure/
    Inventory/
      Inventory.Contracts/
      Inventory.Core/
      Inventory.Infrastructure/
  Host/
  Shared.Kernel/

Orders.Core depends on Inventory.Contracts, not Inventory.Core.

namespace Inventory.Contracts;

public sealed record ReserveStockRequest(Guid ProductId, int Quantity, Guid OrderId);

public sealed record ReserveStockResult(bool Success, string? FailureCode);

public interface IInventoryGateway
{
    Task<ReserveStockResult> ReserveAsync(ReserveStockRequest request, CancellationToken cancellationToken);
}
using Inventory.Contracts;

namespace Orders.Core.Application;

public sealed record PlaceOrderCommand(Guid OrderId, Guid ProductId, int Quantity, Guid CustomerId);

public sealed class PlaceOrderHandler
{
    private readonly IInventoryGateway _inventoryGateway;
    private readonly IOrderRepository _orders;

    public PlaceOrderHandler(IInventoryGateway inventoryGateway, IOrderRepository orders)
    {
        _inventoryGateway = inventoryGateway;
        _orders = orders;
    }

    public async Task<Result> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        if (command.Quantity <= 0)
        {
            return Result.Failure("orders.invalid_quantity");
        }

        var reserve = await _inventoryGateway.ReserveAsync(
            new ReserveStockRequest(command.ProductId, command.Quantity, command.OrderId),
            cancellationToken);

        if (!reserve.Success)
        {
            return Result.Failure(reserve.FailureCode ?? "inventory.unavailable");
        }

        var order = Order.Create(command.OrderId, command.CustomerId, command.ProductId, command.Quantity);
        await _orders.AddAsync(order, cancellationToken);
        return Result.Success();
    }
}
using Inventory.Infrastructure;
using Orders.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Each module owns its own DI registration behind one extension.
builder.Services.AddOrdersModule();
builder.Services.AddInventoryModule();

var app = builder.Build();
app.Run();
using Inventory.Contracts;
using Microsoft.Extensions.DependencyInjection;

namespace Inventory.Infrastructure;

public static class InventoryModuleExtensions
{
    public static IServiceCollection AddInventoryModule(this IServiceCollection services)
    {
        services.AddScoped<IInventoryGateway, InventoryGateway>();
        // Register Inventory DbContext and repositories here.
        return services;
    }
}

This keeps module boundaries enforceable in code review, dependency graphs, and architecture tests.

Extraction Path to Microservices

If boundaries are real, extraction is mechanical instead of a rewrite.

  1. Keep call sites targeting contracts such as IInventoryGateway.
  2. Replace in process implementation with an HTTP or gRPC client implementation behind the same interface.
  3. Move Inventory module runtime to its own deployable service with owned data.
  4. Keep Orders calling code unchanged because the contract shape stays the same.

This is why modular monolith is often a safer first architecture than either a big unstructured monolith or premature microservices. It gives an incremental path from Monolith Architecture toward Microservices only when real scaling or release pressure appears.

Pitfalls

1 Boundary erosion through shortcuts

2 Shared database without isolation

3 Over modularization too early

4 Modular in name only

Tradeoffs

Criterion Traditional Monolith Modular Monolith Microservices
Deployment Single unit Single unit Independent service deployments
Team model Shared ownership across codebase Ownership by module with explicit contracts Ownership by service with strong autonomy
Data isolation Usually shared schema and shared table access Isolated schema or strict table ownership per module Database per service with hard isolation
Runtime overhead Lowest in process calls Low in process calls plus boundary discipline Highest due to network calls and resilience layers
Operational complexity Low Low to medium High observability platform and deployment orchestration needs
Extraction cost High if internals are tangled Low to medium if contracts and data isolation are enforced Not applicable already extracted

Decision rule: default to modular monolith for most product teams, choose traditional monolith only for very small or short lived systems, and move to microservices only when independent deployment or scaling constraints are repeatedly blocking delivery.

Questions

References


Whats next