Skip to main content

Modular Monolith: The Best of Both Worlds

A Modular Monolith combines the deployment simplicity of a monolith with the organizational benefits of microservices.

TL;DR

ConceptDefinition
Core IdeaSingle deployable with strong module boundaries
ModulesIndependent bounded contexts within one application
CommunicationIn-process, but through defined interfaces
EvolutionCan extract modules to services later

Structure

src/
├── Modules/
│ ├── Orders/ # Orders bounded context
│ │ ├── Api/
│ │ ├── Domain/
│ │ └── Infrastructure/
│ ├── Inventory/ # Inventory bounded context
│ └── Shipping/ # Shipping bounded context
├── Shared/
│ ├── Shared.Kernel/ # Shared domain concepts
│ └── Shared.Contracts/ # Integration events
└── Host/
└── Program.cs # Wires everything together

Module Rules

Rule 1: Modules Have Clear Boundaries

<!-- Orders.csproj - NO reference to Inventory! -->
<ProjectReference Include="Shared.Kernel.csproj" />
<ProjectReference Include="Shared.Contracts.csproj" />

Rule 2: Communication Through Contracts

// Orders publishes
await _eventBus.Publish(new OrderPlacedIntegrationEvent(order.Id, ...));

// Inventory subscribes
public class OrderPlacedHandler : IEventHandler<OrderPlacedIntegrationEvent>
{
public async Task Handle(OrderPlacedIntegrationEvent @event)
{
await _inventoryService.Reserve(@event.ProductId, @event.Quantity);
}
}

Rule 3: No Direct Database Access

// ❌ Wrong
return await _dbContext.Orders.FindAsync(orderId); // Accessing Orders from Inventory!

// ✅ Right
return await _ordersModule.GetOrder(orderId); // Use public interface

Rule 4: Modules Own Their Data

modelBuilder.HasDefaultSchema("orders");     // Orders schema
modelBuilder.HasDefaultSchema("inventory"); // Inventory schema

Extraction to Microservices

The modular monolith makes extraction easy:

  • Module has clear public interface
  • All communication is through events/APIs
  • No shared database tables
  • Replace in-process event bus with message queue

Trade-offs

BenefitTrade-off
Simple deploymentSingle point of failure
Fast communicationModules can't scale independently
Easier debuggingLarge codebase
Transaction simplicityMust maintain boundaries