Back to blog
architectureintermediate

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.

LearnixoApril 19, 202610 min read
.NETArchitectureMicroservicesMonolithSystem DesignC#ASP.NET Core
Share:𝕏

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 DB

It 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 week

That 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

C#
// 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

C#
// 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 intervention

Debugging 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 timelines

The 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, Result

Every 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:

C#
// 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();
C#
// 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. β”‚  β”‚
β”‚                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
C#
// 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:

SQL
-- 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
C#
// 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 changes

Signal 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 control

Signal 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 monolith
Before 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.

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.