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.
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
- Each module owns a bounded context — Users, Orders, Payments, Inventory are separate modules with their own domain models
- No shared database tables — Each module has its own schema (or at minimum, its own set of tables that no other module touches directly)
- Explicit public API per module — Modules communicate through defined interfaces, not by reaching into each other's internals
- Enforced boundaries — The compiler prevents violations, not just convention
The Structure in .NET
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, messagingEnforcing 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:
// 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;
}
}// 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:
[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:
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
Inter-Module Communication
Modules need to communicate. Two patterns work well:
Synchronous: Interface-Based Calls
For queries where you need an immediate response:
// 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:
// 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
The Migration Path
If you start with a modular monolith and later need microservices, the migration is straightforward because the boundaries already exist:
- The module's
Contractsassembly becomes a NuGet package or gRPC proto definition - The in-process event dispatcher becomes a Service Bus publisher
- The module's DbContext already has its own schema — split to a separate database
- 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