Skip to main content
All posts
Software Engineering7 min read

Modular Monolith: The Architecture Pattern Enterprises Should Consider Before Microservices

Why a modular monolith offers the bounded-context benefits of microservices without the distributed-system tax — and when to extract services later.

Published

The industry spent a decade evangelising microservices. The pendulum is swinging back — not to the monoliths of 2010, but to something more pragmatic: the modular monolith.

A modular monolith gives you bounded contexts, clear ownership, and independent evolvability — without the distributed-system tax of network calls, eventual consistency, and 47 Kubernetes manifests per service.

This post explains what a modular monolith actually is, how to enforce module boundaries in .NET, and when (not if) you should extract modules into services.

Why Microservices Fail in Most Enterprises

Let us be honest about why microservices disappoint:

Premature decomposition — Teams split their application into services before understanding their domain boundaries. The result is a distributed monolith: services that cannot be deployed independently because they share a database or call each other synchronously for every operation.

Operational overhead — Each service needs CI/CD, monitoring, logging, health checks, service discovery, and an on-call rotation. For a team of 8 engineers managing 20 services, the infrastructure tax consumes more time than feature development.

Data consistency nightmares — Distributed transactions, saga patterns, and eventual consistency are genuinely hard. Most teams underestimate this complexity until they are debugging a production incident at 2am where three services disagree about the state of an order.

Network as a reliability boundary — Every service call is a potential failure point. Retry logic, circuit breakers, timeouts, and fallbacks add code that a monolith simply does not need.

None of this means microservices are wrong. They are right — for specific problems at specific scales. The mistake is adopting them as a default starting architecture.

What a Modular Monolith Actually Is

A modular monolith is a single deployable unit whose internal structure mirrors what you would build with microservices — without the network boundary.

Core Principles

  1. Each module owns a bounded context — Users, Orders, Payments, Inventory are separate modules with their own domain models
  2. No shared database tables — Each module has its own schema (or at minimum, its own set of tables that no other module touches directly)
  3. Explicit public API per module — Modules communicate through defined interfaces, not by reaching into each other's internals
  4. Enforced boundaries — The compiler prevents violations, not just convention

The Structure in .NET

Code
src/
├── Modules/
│   ├── Users/
│   │   ├── Users.Domain/          # Entities, value objects
│   │   ├── Users.Application/     # Use cases, commands, queries
│   │   ├── Users.Infrastructure/  # EF Core, external services
│   │   └── Users.Contracts/       # Public API (interfaces, events, DTOs)
│   ├── Orders/
│   │   ├── Orders.Domain/
│   │   ├── Orders.Application/
│   │   ├── Orders.Infrastructure/
│   │   └── Orders.Contracts/
│   └── Payments/
│       ├── ...
│       └── Payments.Contracts/
├── Host/                          # Composition root, startup, middleware
└── Shared/                        # Cross-cutting: logging, auth, messaging

Enforcing Module Boundaries in .NET

Convention-based boundaries break within weeks. Developers under deadline pressure will take shortcuts — unless the compiler prevents them.

Strategy 1: Assembly-Level Access Control

Each module's Domain, Application, and Infrastructure projects use internal access modifiers by default. Only the Contracts project exposes public types:

Csharp
// Orders.Domain - internal by default
internal sealed class Order
{
    internal OrderId Id { get; private set; }
    internal CustomerId CustomerId { get; private set; }
    internal OrderStatus Status { get; private set; }
    internal Money TotalAmount { get; private set; }

    internal static Order Create(CustomerId customerId, IReadOnlyList<OrderLine> lines)
    {
        var order = new Order
        {
            Id = OrderId.New(),
            CustomerId = customerId,
            Status = OrderStatus.Created,
            TotalAmount = lines.Sum(l => l.Amount)
        };
        order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
        return order;
    }
}
Csharp
// Orders.Contracts - the only public surface
public interface IOrderService
{
    Task<OrderDto> GetOrderAsync(Guid orderId);
    Task<Guid> PlaceOrderAsync(PlaceOrderRequest request);
}

public sealed record OrderDto(Guid Id, Guid CustomerId, string Status, decimal Total);
public sealed record PlaceOrderRequest(Guid CustomerId, IReadOnlyList<OrderLineDto> Lines);

The key: other modules can only reference Orders.Contracts. They cannot see Orders.Domain, Orders.Application, or Orders.Infrastructure.

Strategy 2: ArchUnit Tests

For boundaries that assemblies alone cannot enforce, use architecture tests:

Csharp
[Fact]
public void Orders_Should_Not_Depend_On_Users_Infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .Should()
        .NotHaveDependencyOn("Users.Infrastructure")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

Strategy 3: Separate DbContexts Per Module

Each module has its own DbContext that only maps its own entities:

Csharp
internal sealed class OrdersDbContext : DbContext
{
    // Only Orders tables — no User or Payment entities here
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderLine> OrderLines => Set<OrderLine>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("orders"); // Schema-level isolation
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
    }
}

This prevents the single God-DbContext problem where any module can query any table.

Module Boundary Architecture

Loading diagram...

Inter-Module Communication

Modules need to communicate. Two patterns work well:

Synchronous: Interface-Based Calls

For queries where you need an immediate response:

Csharp
// In Orders module, calling Users module through its contract
public sealed class PlaceOrderHandler : ICommandHandler<PlaceOrderCommand, Guid>
{
    private readonly IUserService _userService; // From Users.Contracts

    public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        var user = await _userService.GetUserAsync(command.CustomerId);
        if (user is null) throw new UserNotFoundException(command.CustomerId);
        // ... create order
    }
}

Asynchronous: Domain Events

For notifications where the caller does not need to wait:

Csharp
// Published by Orders module
public sealed record OrderPlacedEvent(Guid OrderId, Guid CustomerId, decimal Amount) : IDomainEvent;

// Handled by Payments module
internal sealed class OrderPlacedHandler : IDomainEventHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
    {
        // Create payment intent, reserve funds, etc.
    }
}

In a monolith, these events are dispatched in-process via MediatR or a simple event bus. When you later extract to microservices, you swap the in-process dispatcher for a message broker — the handler code barely changes.

When to Extract a Module Into a Microservice

The modular monolith is not a permanent destination for every module. It is a starting position that allows informed extraction later.

Extract when:

  • A module needs independent scaling (e.g., image processing that needs GPU instances)
  • A module has a radically different deployment cadence (e.g., ML model updates daily while core app ships biweekly)
  • A module requires a different technology (e.g., Python for ML, while the core is .NET)
  • A team has grown large enough (8+ engineers on one module) that deploy coordination is expensive

Do not extract when:

  • "It feels like it should be a service" — feeling is not a reason
  • "We want team autonomy" — module boundaries already provide this
  • "Microservices are best practice" — they are a trade-off, not a best practice
  • The module has high coupling to other modules — extracting will create a distributed monolith

Extraction Decision Flow

Loading diagram...

The Migration Path

If you start with a modular monolith and later need microservices, the migration is straightforward because the boundaries already exist:

  1. The module's Contracts assembly becomes a NuGet package or gRPC proto definition
  2. The in-process event dispatcher becomes a Service Bus publisher
  3. The module's DbContext already has its own schema — split to a separate database
  4. The module's DI registration becomes a separate service's Program.cs

This takes days, not months — because you already did the hard domain decomposition work.

Real-World Guidance

We recommend the modular monolith as the default architecture for enterprise applications with:

  • Fewer than 50 engineers working on the system
  • No extreme scaling differences between modules
  • A domain that is still being explored (boundaries may shift)
  • A timeline that does not allow for distributed-system infrastructure investment

Start modular. Extract surgically. Let production traffic patterns — not architecture diagrams — tell you when distribution is necessary.


Want to design a modular architecture for your enterprise application? Contact us — we help teams find the right architectural boundaries without over-engineering.

Topics

modular monolithmicroservices alternativebounded contexts.NET architectureenterprise application architecture

Frequently Asked Questions

A modular monolith is a single deployable application whose internal structure is divided into well-defined modules with strict boundary enforcement. Each module owns its domain, data, and API surface — similar to a microservice — but all modules run in the same process and deploy together.

Expert engagement

Need expert guidance?

Our team specializes in cloud architecture, security, AI platforms, and DevSecOps. Let's discuss how we can help your organization.

Get in touchNo commitment · No sales pressure

Related articles

All posts