Back to blog
Backend Systemsbeginner

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.

Asma HafeezApril 17, 20263 min read
architecturemodular-monolithdotnetmodulesclean-architecture
Share:𝕏

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 setup

Module Contracts

Each module exposes a public interface. Other modules only call through it.

C#
// 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.

C#
// 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.

C#
// 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

C#
// 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

  1. A modular monolith enforces explicit boundaries without the operational complexity of microservices
  2. Modules communicate through public interfaces — never by calling internal implementations directly
  3. Schema separation in the same database mimics per-service databases without the distributed systems overhead
  4. Internal events decouple modules without network calls
  5. 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.