Monolith vs Microservices in .NET: The Decision That Defines Your First Year
Starting a .NET project with microservices is almost always a mistake β but nobody tells you why, or what to do instead. This guide covers modular monolith patterns, the real cost of premature distribution, and the exact signals that tell you it's time to extract a service.
The most expensive architectural decision most teams make is not choosing microservices. It's choosing microservices before they need them.
This guide explains why, what to do instead, and the exact conditions that make microservices the right call.
The Seductive Diagram Problem
Every microservices talk shows a slide like this:
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Orders ββββββΆβ Payments ββββββΆβ Shipping β
β Service β β Service β β Service β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β β β
βΌ βΌ βΌ
Orders DB Payments DB Shipping DBIt looks clean. It looks scalable. It looks like what Amazon and Netflix do.
What the slide doesn't show:
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Orders ββββββΆβ Payments ββββββΆβ Shipping β
β Service β β Service β β Service β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β β β β
βΌ β βΌ βΌ
Orders DB ββββββββββββββββββββ Shipping DB
β Message Broker β
β (RabbitMQ/Kafka)β β now you need this
ββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
Service Mesh Distributed Shared Auth
(Istio/Linkerd) Tracing (IdentityServer)
β 2 weeks (Jaeger) β 3 weeks
β 1 weekThat second diagram is what the first 2 months of your project actually looks like.
What Microservices Actually Cost
Complexity you don't see until you're in it
Network calls replace function calls
// Monolith β one line, one stack frame, instant
var order = _orderService.GetOrder(orderId);
// Microservices β HTTP call, serialization, timeout, retry, circuit breaker
var order = await _orderClient.GetOrderAsync(orderId, ct);
// What happens when this times out?
// What happens when the service is down?
// What happens when it returns a 200 with a malformed body?
// What happens during a deployment when v1 and v2 coexist?Transactions become distributed transactions
// Monolith β one database transaction, ACID guaranteed
using var tx = await _db.Database.BeginTransactionAsync();
await _orderRepo.CreateAsync(order);
await _inventoryRepo.DeductAsync(order.Items);
await _paymentRepo.ChargeAsync(order.Total);
await tx.CommitAsync();
// Microservices β now you need sagas, compensating transactions,
// and eventual consistency. A 3-line monolith operation becomes:
// 1. OrderService creates order (state: PENDING)
// 2. Publishes OrderCreated event
// 3. InventoryService consumes event, deducts stock, publishes StockReserved
// 4. PaymentService consumes event, charges card, publishes PaymentCompleted
// 5. OrderService updates order to CONFIRMED
// What if step 4 fails after step 3 succeeded?
// β compensating transaction: publish StockRelease event
// What if the compensating event is lost?
// β dead letter queue + manual interventionDebugging spans services
// Monolith β full stack trace, one log file, one process
System.NullReferenceException at OrderService.cs:47
at OrderProcessor.Process() in OrderProcessor.cs:112
at OrderController.Post() in OrderController.cs:23
// Microservices β trace ID across 4 services, 4 log systems
[order-service] traceId=abc123 Processing order 456
[inventory-svc] traceId=abc123 Reserving stock for order 456
[payment-svc] traceId=abc123 Charging card... timeout after 30s
[order-service] traceId=abc123 Payment result: ???
// Now open Jaeger, search for traceId, correlate 4 timelinesThe Modular Monolith: What You Should Build Instead
A modular monolith gives you clean separation of concerns, independent deployability of modules (eventually), and none of the distributed systems overhead β until you actually need it.
The Structure
src/
βββ YourApp.Api/ β Single ASP.NET Core host
β βββ Program.cs
β
βββ YourApp.Orders/ β Orders module (bounded context)
β βββ OrdersModule.cs β registers its own DI, routes
β βββ Domain/
β β βββ Order.cs
β β βββ OrderStatus.cs
β βββ Application/
β β βββ CreateOrderCommand.cs
β β βββ CreateOrderHandler.cs
β βββ Infrastructure/
β β βββ OrderRepository.cs
β βββ Api/
β βββ OrdersController.cs
β
βββ YourApp.Payments/ β Payments module (separate bounded context)
β βββ PaymentsModule.cs
β βββ Domain/
β βββ Application/
β βββ Infrastructure/
β βββ Api/
β
βββ YourApp.Shared/ β Shared kernel (minimal β only what must be shared)
βββ Events/ β Integration event contracts
βββ Primitives/ β ValueObjects, ResultEvery module owns its own:
- Domain models
- Database tables (even if they share one database)
- Application logic (commands, queries, handlers)
- API controllers
- DI registration
No module reaches into another module's internals.
Module Registration
Each module registers itself. The host just calls each module's setup:
// Program.cs β the host knows almost nothing
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOrdersModule(builder.Configuration);
builder.Services.AddPaymentsModule(builder.Configuration);
builder.Services.AddShippingModule(builder.Configuration);
var app = builder.Build();
app.MapOrdersEndpoints();
app.MapPaymentsEndpoints();
app.MapShippingEndpoints();
app.Run();// OrdersModule.cs β the module owns its setup
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(
this IServiceCollection services,
IConfiguration config)
{
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<CreateOrderHandler>();
services.AddDbContext<OrdersDbContext>(opt =>
opt.UseSqlServer(config.GetConnectionString("Orders")));
return services;
}
public static IEndpointRouteBuilder MapOrdersEndpoints(
this IEndpointRouteBuilder app)
{
app.MapGroup("/api/orders")
.MapOrderRoutes();
return app;
}
}Cross-Module Communication: In-Process Events
Modules communicate through events β not direct method calls. This is the crucial rule that makes extraction possible later.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Single Process β
β β
β βββββββββββββββ event βββββββββββββββββββββββ β
β β Orders βββββββββββββββΆβ In-Process Event β β
β β Module β β Bus β β
β βββββββββββββββ βββββββββββ¬ββββββββββββ β
β β β
β βββββββββββββββΌβββββββββββ β
β βΌ βΌ βΌ β
β ββββββββββββ ββββββββββββ ββββββββ β
β βPayments β βInventory β βEmail β β
β β Module β β Module β β Mod. β β
β ββββββββββββ ββββββββββββ ββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ// Shared event contract
public record OrderPlacedEvent(
Guid OrderId,
Guid CustomerId,
decimal Total,
List<OrderLineItem> Items
);
// Orders module publishes β doesn't know who listens
public class CreateOrderHandler
{
private readonly IOrderRepository _repo;
private readonly IEventBus _bus;
public async Task<Guid> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
await _repo.SaveAsync(order, ct);
// Fire and forget β Payments module handles this
await _bus.PublishAsync(new OrderPlacedEvent(
order.Id, order.CustomerId, order.Total, order.Items
), ct);
return order.Id;
}
}
// Payments module listens β doesn't know who published
public class ChargeForOrderHandler : IEventHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
{
await _paymentService.ChargeAsync(evt.CustomerId, evt.Total, ct);
}
}When you eventually extract Payments into its own service, you swap the in-process event bus for a real message broker (RabbitMQ / Azure Service Bus). The handler code doesn't change. Only the transport changes.
Separate Database Schemas Per Module
One database, but each module owns its own schema. No foreign keys across schemas:
-- Orders module owns this
CREATE SCHEMA orders;
CREATE TABLE orders.orders (id uniqueidentifier, ...);
CREATE TABLE orders.order_lines (id uniqueidentifier, order_id uniqueidentifier, ...);
-- Payments module owns this
CREATE SCHEMA payments;
CREATE TABLE payments.payment_records (id uniqueidentifier, order_id uniqueidentifier, ...);
-- β No foreign key from payments.payment_records.order_id β orders.orders.id
-- Modules are loosely coupled by convention, not by DB constraint// Each module has its own DbContext scoped to its schema
public class OrdersDbContext : DbContext
{
public DbSet<OrderRow> Orders { get; set; }
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("orders"); // all tables go in orders schema
}
}
public class PaymentsDbContext : DbContext
{
public DbSet<PaymentRow> Payments { get; set; }
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("payments");
}
}When you split into microservices, you split the database too β each service gets its own connection string. The schema isolation you already enforced makes this a clean cut.
When Microservices Are Actually Right
The answer is almost never "at the start." The signals that tell you it's time:
Signal 1: An Independent Scaling Requirement
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Orders: 100 req/s β 1 instance is fine β
β Product Search: 5000 req/s β needs 20 instances β
β Checkout: 200 req/s β 2 instances is fine β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββIf Product Search needs 20 instances but you're scaling the whole monolith, you're running 20 copies of Orders and Checkout for no reason. This is the real microservices use case β not theoretical, not architectural cleanliness.
Signal 2: An Independent Deployment Requirement
Different teams own different modules and need to deploy on different cycles:
Team A (Orders) deploys every 2 weeks β careful, regulated
Team B (Recommendations) deploys 10 times a day β ML model updates
If they share a process:
β Team B is blocked by Team A's deployment freeze
β Team A is destabilized by Team B's rapid changesSignal 3: A Hard Technology Boundary
// .NET can't do this efficiently
ML inference at 10ms latency β Python + TensorFlow / PyTorch
// .NET can't run this
Legacy COBOL billing system β its own process, integration layer
// You need a different runtime
Real-time game server β C++ / Go for memory controlSignal 4: Security or Compliance Isolation
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β Main Application β β Payment Processing β
β β β (PCI-DSS scope) β
β No card data here ββββββΆβ Card data here only β
β β β Separate network β
ββββββββββββββββββββββββ ββββββββββββββββββββββββPCI-DSS, HIPAA, or similar require hard isolation. This is a valid reason β but it's a compliance requirement, not an architectural preference.
The Migration Path: When You're Ready
When you extract a module, the steps are the same regardless of which module:
Step 1: Verify the module has no cross-module DB foreign keys
(you enforced this with separate schemas β β
already done)
Step 2: Verify all cross-module communication goes through events
(you enforced this with the event bus β β
already done)
Step 3: Move the module's code to a new repo/solution
Step 4: Give it its own database (copy schema, migrate data)
Step 5: Replace the in-process event bus with a real message broker
Step 6: Update the event publisher in the monolith to publish to the broker
Step 7: The extracted service subscribes from the broker
Step 8: Delete the module from the monolithBefore extraction:
βββββββββββββββββββββββββββββββββββββββ
β Monolith β
β Orders ββevent busβββΆ Payments β
βββββββββββββββββββββββββββββββββββββββ
After extraction:
ββββββββββββββββ RabbitMQ βββββββββββββββββββββββ
β Monolith βββββββββββββββΆ β Payments Service β
β (Orders) β β (extracted) β
ββββββββββββββββ βββββββββββββββββββββββThe event handler in Payments doesn't change. The domain logic doesn't change. You're only swapping the transport β in-process β message broker.
A Decision Framework
Are you a startup / new project?
β
βββββββββββ΄ββββββββββ
Yes No
β β
βΌ βΌ
Build a modular Do specific modules
monolith have different scaling,
deployment, or tech needs?
β
ββββββββββββ΄βββββββββββ
Yes No
β β
βΌ βΌ
Extract that specific Keep the modular
module as a service monolith β you're done
(not all of them)What the Real Companies Did
Amazon β Started as a monolith (BookSurge/Amazon.com was a Perl CGI app). Moved to services when specific teams needed independent deployments. Took years.
Netflix β Ran a monolith until 2008 database corruption forced a rethink. Migrated to microservices over 7 years. Still references the period as painful.
Stack Overflow β Runs one of the highest-traffic developer sites in the world on a small monolith cluster. 9 web servers. Not microservices. Proven at massive scale.
Basecamp / Shopify β Explicitly advocate for the majestic monolith. Both run large, modular, profitable codebases.
The common thread: they reached for microservices when they hit specific, measurable problems β not as a starting assumption.
Summary
| | Modular Monolith | Microservices | |---|---|---| | Start-up speed | Fast β one deploy, one process | Slow β infra first | | Debugging | One log stream, one trace | Distributed traces, multiple logs | | Transactions | ACID, one DB | Sagas, eventual consistency | | Scaling | Whole app scales together | Each service scales independently | | Team independence | Harder at high team count | Each team deploys independently | | Operations | Simple | K8s, service mesh, secret management | | Right when | Starting out, < 5 teams | Specific scaling/deployment needs proven |
Build the modular monolith. Enforce module boundaries from day one. Use in-process events between modules. Separate your database schemas.
Then, when you hit a real, measured, specific reason to extract β you already have a clean cut point.
The best architecture is the one that ships.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.