Modular Monolith vs Microservices in .NET: How to Choose
"Should we build this as microservices?" is one of the first questions I get on new .NET projects. My honest answer is usually "not yet". Microservices solve real problems, but they are organisational and operational problems, not coding problems. For most products a well-structured modular monolith delivers faster, costs less to run and can still be split later. This article explains how to decide, and how to build a modular monolith in .NET that keeps that door open.
Three architectures, defined honestly
- Traditional monolith: one deployable, one database, and no enforced internal boundaries. Any class can call any other class and any query can join any table. This is the "big ball of mud" people are running away from.
- Modular monolith: one deployable, but split into modules with explicit boundaries (Orders, Billing, Catalog, Identity). Each module owns its data and exposes a small public API; other modules cannot reach into its internals.
- Microservices: each module is a separately deployed service with its own database, talking over the network (HTTP, gRPC or messages).
The key insight is that boundaries are what make a system maintainable. Microservices enforce boundaries with the network. A modular monolith enforces them with the compiler and a few rules, which is far cheaper.
The real cost of microservices
Teams underestimate what they sign up for. With microservices you now own:
- Distributed failures: timeouts, retries, partial failures and idempotency on every call.
- Data consistency without transactions: sagas, outbox patterns and eventual consistency.
- Observability across services: distributed tracing (OpenTelemetry), correlated logs and metrics.
- Deployment infrastructure: containers, an orchestrator (AKS, Container Apps), service discovery, a pipeline per service.
- Versioned contracts between services, and the coordination to change them.
- Local development that needs ten things running before you can debug one feature.
For a team of 3 to 10 developers that overhead can easily eat a third of the capacity, and the main promised benefit (independent teams deploying independently) doesn't exist yet because there is only one team.
A decision checklist
Microservices start to pay off when several of these are true:
| Signal | Modular monolith | Microservices |
|---|---|---|
| Team size | 1 to 3 teams | Many teams that need to ship independently |
| Domain understanding | Still evolving; boundaries may move | Stable, well-understood boundaries |
| Scaling needs | Whole app scales together fine | One part needs 50× the resources of the rest |
| Release cadence | Shared release is acceptable | Parts must release on different schedules |
| Technology | One stack (.NET) is fine | Parts genuinely need different stacks or runtimes |
| Compliance / isolation | Logical isolation is enough | Hard isolation of a component is required |
| Ops maturity | Limited DevOps capacity | Strong platform/DevOps practice already in place |
If you mostly ticked the left column, build a modular monolith. If you ticked the right column for one specific area (for example a CPU-heavy document processing pipeline), extract just that area as a service and keep the rest together.
Building a modular monolith in .NET
One project per module, with a public contract
src/
Host/ // ASP.NET Core app: composition root only
Modules/
Orders/
Orders.Contracts/ // public: integration events, query interfaces, DTOs
Orders.Core/ // internal: domain, use cases, EF Core DbContext
Billing/
Billing.Contracts/
Billing.Core/
BuildingBlocks/ // small: Result type, event bus abstraction
Other modules may reference Orders.Contracts but never Orders.Core. Make everything in Core internal by default; only the module's registration extension method is public:
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<OrdersDbContext>(o =>
o.UseSqlServer(config.GetConnectionString("Main"),
sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));
services.AddScoped<IOrdersQueries, OrdersQueries>(); // exposed via Orders.Contracts
return services;
}
public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").RequireAuthorization();
// ... endpoints
return app;
}
}
Each module owns its data
Use one database if you like, but give each module its own schema and its own DbContext. The golden rule: no cross-module joins and no shared tables. If Billing needs order data, it asks the Orders module through its contract or keeps its own copy updated by events. This single rule is what makes a later extraction to a microservice realistic.
Communicate in-process, asynchronously where it matters
Synchronous queries between modules go through interfaces in the Contracts project. Side effects ("when an order is paid, issue an invoice") go through integration events. Start with an in-memory event bus plus an outbox table so events aren't lost if the process crashes mid-request:
public sealed record OrderPaid(Guid OrderId, decimal Amount, DateTimeOffset PaidAt) : IIntegrationEvent;
internal sealed class IssueInvoiceOnOrderPaid(BillingDbContext db) : IIntegrationEventHandler<OrderPaid>
{
public async Task HandleAsync(OrderPaid e, CancellationToken ct)
{
db.Invoices.Add(Invoice.For(e.OrderId, e.Amount, e.PaidAt));
await db.SaveChangesAsync(ct);
}
}
When you later move Billing into its own service, the handler stays the same; only the transport changes from in-memory to Azure Service Bus or RabbitMQ.
Enforce the boundaries with tests
Write architecture tests that fail the build if Billing.Core references Orders.Core, or if a module's DbContext maps another module's tables. Boundaries that are only documented will erode within months.
Extracting a service later
With the structure above, extracting a module is a mostly mechanical job:
- Create a new host project that registers only that module.
- Move its schema to its own database (it already has no cross-module joins).
- Replace in-process contract calls with HTTP or gRPC clients implementing the same interface.
- Switch the event bus for that module to a real broker.
- Route traffic with a gateway such as YARP while both versions run.
Compare that with splitting a traditional monolith, where the first months go into discovering which tables and classes are secretly shared.
The pragmatic middle ground
Most of the systems I design end up as a modular monolith plus a small number of satellite services: a background worker for heavy jobs, a separate service for a component with very different scaling needs, and managed cloud services (queues, search, blob storage) instead of self-built ones. That gives you most of the benefits of microservices at a fraction of the operational cost. .NET Aspire is a good fit for running and observing this kind of setup locally and in Azure.
Summary
Choose microservices for organisational reasons (many teams, independent releases, very different scaling needs), not because they feel modern. For most products, start with a modular monolith: clear modules, private internals, one schema per module, and events for side effects. You'll ship faster now and keep the option to split later.
Unsure which architecture fits your product? I run architecture reviews and help teams plan migrations. Read more about my architecture design service or contact me.