Introduction
Most teams jump straight from a monolith to microservices, only to discover they've traded one set of problems for another. The reality we've seen across e-commerce, SaaS, and travel platforms is that a modular monolith in .NET: a hands-on guide for engineers sits in the sweet spot—you get the deployment simplicity of a monolith with the organizational clarity of microservices. We'll show you how to structure this architecture so that your codebase stays maintainable as it scales, and how a well-bounded module can transition to a true microservice later when the business case justifies it.
One Deployable, Many Modules — The Foundation
A modular monolith runs as a single deployable unit, but internally, it's organized into separate modules with their own responsibilities, schemas, and data access layers. Think of it like an office building: one structure, but each department operates in its own suite.
Let's say we're building an e-commerce platform with Orders, Billing, and Catalog modules. Instead of sharing a single database schema, each module owns its own DbContext and database tables. This is the critical distinction from a traditional monolith where everything lives in one schema.
Project structure looks like this:
src/
├── Ecommerce.API/ // Main entry point
├── Ecommerce.Modules.Orders/
│ ├── Data/
│ │ ├── OrdersDbContext.cs
│ │ └── Migrations/
│ ├── Models/
│ ├── Services/
│ └── Orders.csproj
├── Ecommerce.Modules.Billing/
│ ├── Data/
│ │ ├── BillingDbContext.cs
│ │ └── Migrations/
│ ├── Models/
│ ├── Services/
│ └── Billing.csproj
├── Ecommerce.Modules.Catalog/
│ ├── Data/
│ │ ├── CatalogDbContext.cs
│ │ └── Migrations/
│ ├── Models/
│ ├── Services/
│ └── Catalog.csproj
└── Ecommerce.Shared/ // Only types, not logic
├── Events/
└── Contracts/Each module manages its own migrations independently. The Orders module doesn't know about Billing's schema, and vice versa. This isolation means you can evolve each module's database independently—add a column to the OrderItems table without coordinating with the billing team.
When you need to deploy, you rebuild and ship the entire application. No separate orchestration, no eventual consistency nightmares at 3 a.m. But your team structure, code ownership, and data governance are already organized as if services existed.
Public Contracts Only — Communication Boundaries
The heart of a modular monolith is enforcing that modules communicate through well-defined public interfaces, never by reaching into each other's database tables or internal classes.
In the Billing module, you'd define a public contract for what Orders needs to know:
// Billing.Contracts/IBillingService.cs
namespace Ecommerce.Modules.Billing.Contracts;
public interface IBillingService
{
Task<Invoice> GenerateInvoiceAsync(long orderId);
Task<bool> ProcessPaymentAsync(PaymentRequest request);
}
// Billing.Contracts/Invoice.cs
public record Invoice(
long InvoiceId,
long OrderId,
decimal Amount,
DateTime IssuedAt
);The Orders module only sees this interface and these DTOs, never the internal BillingDbContext or domain entities. Here's how Orders uses it:
// Orders/Services/OrderService.cs
namespace Ecommerce.Modules.Orders.Services;
public class OrderService
{
private readonly OrdersDbContext _dbContext;
private readonly IBillingService _billingService;
public OrderService(OrdersDbContext dbContext, IBillingService billingService)
{
_dbContext = dbContext;
_billingService = billingService;
}
public async Task<OrderDto> CreateOrderAsync(CreateOrderRequest request)
{
var order = new Order { /* ... */ };
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
// Triggers billing module via public contract
var invoice = await _billingService.GenerateInvoiceAsync(order.Id);
return new OrderDto { OrderId = order.Id, InvoiceId = invoice.InvoiceId };
}
}For asynchronous, decoupled communication, we use in-process events. When an order is created, the Orders module publishes a domain event that the Billing module subscribes to—no direct method call, no tight coupling.
// Shared/Events/DomainEvent.cs
public abstract record DomainEvent(DateTime OccurredAt)
{
public Guid CorrelationId { get; init; } = Guid.NewGuid();
}
public record OrderCreatedEvent(long OrderId, decimal Amount, string CustomerId)
: DomainEvent(DateTime.UtcNow);
// Orders/Services/OrderService.cs (updated)
public class OrderService
{
private readonly OrdersDbContext _dbContext;
private readonly IEventPublisher _eventPublisher;
public async Task<OrderDto> CreateOrderAsync(CreateOrderRequest request)
{
var order = new Order { /* ... */ };
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
// Publish domain event
await _eventPublisher.PublishAsync(
new OrderCreatedEvent(order.Id, order.TotalAmount, order.CustomerId)
);
return new OrderDto { OrderId = order.Id };
}
}
// Billing/Handlers/OrderCreatedEventHandler.cs
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly BillingDbContext _dbContext;
public async Task HandleAsync(OrderCreatedEvent @event)
{
var invoice = new Invoice
{
OrderId = @event.OrderId,
Amount = @event.Amount,
CustomerId = @event.CustomerId,
IssuedAt = DateTime.UtcNow
};
_dbContext.Invoices.Add(invoice);
await _dbContext.SaveChangesAsync();
}
}This pattern keeps modules independent while allowing them to react to significant business events. The Catalog module can also subscribe to OrderCreatedEvent to update inventory without any of the modules knowing about each other's internal workings.
Enforce Boundaries — Architecture Tests and Internal Classes
Declaring a boundary and actually enforcing it are different things. We've all seen a project where "internal" classes are public in name only, and dependencies flow backward through utility namespaces. Architecture tests fix this.
Using a library like NetArchTest, you can assert that forbidden dependencies never leak into your codebase:
// Tests/ArchitectureTests.cs
using NetArchTest.Rules;
using Xunit;
public class ModuleBoundaryTests
{
[Fact]
public void OrdersModule_ShouldNotReferenceBillingInternals()
{
var result = Types
.InNamespace("Ecommerce.Modules.Orders")
.Should()
.NotHaveDependencyOn("Ecommerce.Modules.Billing.Data")
.Check();
Assert.True(result.IsSuccessful,
"Orders module must not reference Billing's DbContext or internal classes");
}
[Fact]
public void AllModules_ShouldOnlyUseContracts()
{
var result = Types
.InNamespace("Ecommerce.Modules")
.Should()
.NotHaveDependencyOn("Ecommerce.Modules.*.Services")
.AllowEmptyTypes(true)
.Check();
Assert.True(result.IsSuccessful,
"Modules should depend on contracts, not concrete service implementations");
}
[Fact]
public void EventHandlers_ShouldBelongToTheirModule()
{
var result = Types
.InNamespace("Ecommerce.Modules.Billing.Handlers")
.Should()
.BeInternal()
.Check();
Assert.True(result.IsSuccessful,
"Event handlers are implementation details and should remain internal");
}
}Combine this with C# access modifiers as your first line of defense. Mark all internal classes as internal—it's the default, but making it explicit signals intent:
// Billing/Data/BillingDbContext.cs
namespace Ecommerce.Modules.Billing.Data;
internal class BillingDbContext : DbContext
{
public BillingDbContext(DbContextOptions<BillingDbContext> options)
: base(options) { }
public DbSet<Invoice> Invoices { get; set; }
}
// Billing/Services/InvoiceGenerator.cs
namespace Ecommerce.Modules.Billing.Services;
internal class InvoiceGenerator
{
// Implementation details
}Only expose what the rest of the application needs. In a feature-flagging module, for example, expose the service and the flag types, but keep the flag repository and configuration loader internal.
Run these tests on every build in CI. Failing fast on architectural violations prevents technical debt from accumulating.
Extract When It Hurts — The Path to Microservices
One of the biggest wins of a well-designed modular monolith is that extracting a module into a microservice becomes a straightforward exercise, not a rewrite.
Let's say your Billing module has grown complex: it integrates with multiple payment gateways, has its own compliance requirements, and scales differently than the rest of the application. You decide to extract it as a microservice. Because you've maintained boundaries, the extraction involves:
- Move the
Billingmodule to its own repository and .NET project. - Replace the in-process event subscription with an event bus (RabbitMQ, AWS SQS, etc.).
- Replace the in-process interface injection with an HTTP or gRPC client.
- Deploy independently.
The contracts and event types remain unchanged. The Orders module's code barely changes:
// Before: In-process
services.AddScoped<IBillingService, BillingService>();
// After: HTTP client
services.AddHttpClient<IBillingService, BillingServiceHttpClient>(client =>
{
client.BaseAddress = new Uri(configuration["BillingService:Url"]);
client.Timeout = TimeSpan.FromSeconds(5);
});Because you never had direct database access between modules, there's no distributed transaction nightmare. The event-based communication pattern already handles eventual consistency.
This is why we recommend starting with a modular monolith: it lets you defer the complexity of distributed systems until you actually need it. You avoid premature microservice extraction—a common source of operational burden and cost.
Common Mistakes
- Shared database schemas: Modules that join across each other's tables create hidden dependencies and make extraction nearly impossible. Each module owns its schema.
- Vague contracts: Public interfaces with generic names like
IServiceor overly broad methods encourage coupling. Be specific:IBillingServicewith explicit methods likeGenerateInvoiceAsync. - Circular dependencies: Orders calls Billing, Billing calls Catalog—this quickly becomes unsustainable. Use a event-driven architecture to break cycles.
- Skipping tests: Architecture tests feel optional until a junior developer adds a direct reference to another module's DbContext and nobody catches it until production. Run them in CI.
- Extracting too early: If a module is still evolving rapidly or shares business logic with others, keep it in the monolith. Microservices have real costs: deployment complexity, observability, network latency.
Key Takeaways
- A modular monolith combines single-deployment simplicity with the organizational clarity of microservices.
- Each module owns its own DbContext and database schema, eliminating hidden cross-module dependencies.
- Communicate through public contracts (interfaces and DTOs) and domain events, never through direct database access or internal classes.
- Enforce boundaries with architecture tests and C# access modifiers; violations caught in CI, not production.
- A well-bounded module becomes a microservice with minimal refactoring when the business case justifies extraction.
- Start here before jumping to distributed systems. You'll make better scaling decisions with real operational data.






Comments
Loading comments…