ASP.NET Core  

Scaling Microservices via a Monolith in ASP.NET Core

Introduction

Microservices have become the default architectural aspiration for modern software systems. However, many ASP.NET Core projects fail not because microservices are bad but because they are introduced too early.

Teams often underestimate the cost of distributed systems: network latency, data consistency, observability, DevOps maturity, and operational complexity. A poorly designed microservices architecture can slow development more than a well-structured monolith ever would.

This article presents a pragmatic, end-to-end approach:
designing a scalable monolith in ASP.NET Core that can evolve into microservices when scale demands it.

This strategy is widely adopted by high-scale engineering teams and is sometimes called

  • Modular Monolith

  • Microservices-Ready Monolith

  • Evolvable Architecture

Why Start with a Monolith?

A monolith is not the problem.
A poorly structured monolith it is.

Benefits of Starting with a Modular Monolith

  • Faster initial development

  • Simple deployment and debugging

  • Strong consistency guarantees

  • Lower infrastructure cost

  • Clear domain understanding before distribution

When Microservices Actually Make Sense

You should extract microservices only when:

  • Modules require independent scaling

  • Deployment frequency differs per domain

  • Teams are independently owned

  • Performance bottlenecks are isolated

  • Business boundaries are stable

Until then, a modular monolith gives you 80% of the benefits with 20% of the cost.

High-Level Architecture

The goal is to build microservices inside a single ASP.NET Core process.

┌───────────────────────────────────────┐
│           ASP.NET Core App            │
│                                       │
│  ┌──────────┐   ┌──────────┐          │
│  │ Catalog  │   │ Orders   │          │
│  │ Module   │   │ Module   │          │
│  └──────────┘   └──────────┘          │
│        │              │               │
│  ┌──────────┐   ┌──────────┐          │
│  │ Identity │   │ Payments │            │
│  │ Module   │   │ Module   │          │
│  └──────────┘   └──────────┘          │
│                                       │
└───────────────────────────────────────┘

Each module:

  • Owns its domain

  • Owns its data

  • Can be extracted without rewriting business logic

Step 1: Solution and Project Structure

Recommended Solution Layout

src/
 ├── Web.Api
 │    ├── Program.cs
 │    ├── Controllers
 │
 ├── Modules/
 │    ├── Catalog/
 │    │    ├── Catalog.Domain
 │    │    ├── Catalog.Application
 │    │    ├── Catalog.Infrastructure
 │    │
 │    ├── Orders/
 │    │    ├── Orders.Domain
 │    │    ├── Orders.Application
 │    │    ├── Orders.Infrastructure
 │
 ├── Shared/
 │    ├── BuildingBlocks
 │    │    ├── Domain
 │    │    ├── Events
 │    │    ├── Messaging

Why This Structure Works

  • Domain isolation is enforced at compile time

  • Infrastructure details never leak into business logic

  • Modules can be moved to independent services later

  • Testing becomes trivial

Step 2: Domain-Driven Design per Module

Example: Catalog Domain Entity

public class Product
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public decimal Price { get; private set; }
    public int Stock { get; private set; }

    public void ReduceStock(int quantity)
    {
        if (Stock < quantity)
            throw new InvalidOperationException("Insufficient stock");

        Stock -= quantity;
    }
}

✔ No dependencies
✔ Business rules only
✔ Infrastructure-free

Step 3: Application Layer (Use Cases)

public class CreateProductCommand
{
    public string Name { get; set; }
    public decimal Price { get; set; }
}
public class CreateProductHandler
{
    private readonly IProductRepository _repository;

    public async Task Handle(CreateProductCommand command)
    {
        var product = new Product(command.Name, command.Price);
        await _repository.AddAsync(product);
    }
}

This layer orchestrates business rules and does not know how data is stored.

Step 4: Infrastructure Layer with Independent DbContext

Database per Module (Even Inside One Database)

public class CatalogDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    public CatalogDbContext(DbContextOptions options)
        : base(options) { }
}

Important Rules

  • Each module has its own DbContext

  • No cross-module joins

  • No shared entity references

This is the single most important rule for future microservice extraction.

Step 5: Module Registration in ASP.NET Core

public static class CatalogModule
{
    public static IServiceCollection AddCatalogModule(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddDbContext<CatalogDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Default")));

        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
builder.Services.AddCatalogModule(builder.Configuration);
builder.Services.AddOrdersModule(builder.Configuration);

Each module is self-contained and plug-and-play.

Step 6: Inter-Module Communication (In-Process)

Never reference another module’s classes directly.

Define a Contract

public interface IProductAvailabilityService
{
    Task<bool> IsAvailableAsync(Guid productId, int quantity);
}

Implementation Inside Catalog Module

public class ProductAvailabilityService : IProductAvailabilityService
{
    private readonly CatalogDbContext _db;

    public async Task<bool> IsAvailableAsync(Guid productId, int quantity)
    {
        var product = await _db.Products.FindAsync(productId);
        return product != null && product.Stock >= quantity;
    }
}

Consumed by Orders Module

public class OrderService
{
    private readonly IProductAvailabilityService _availability;

    public async Task PlaceOrder(Guid productId, int qty)
    {
        if (!await _availability.IsAvailableAsync(productId, qty))
            throw new Exception("Product not available");
    }
}

This is exactly how future microservices communicate—just without HTTP.

Step 7: Domain Events for Loose Coupling

Define a Domain Event

public record OrderPlacedEvent(Guid OrderId);

Publish Event

await _eventBus.PublishAsync(new OrderPlacedEvent(order.Id));

Handle in Another Module

public class InventoryHandler :
    IEventHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent @event)
    {
        // Update stock
    }
}

Later, this can be replaced with:

  • RabbitMQ

  • Azure Service Bus

  • Kafka

Without changing domain logic.

Step 8: Observability from Day One

Even monoliths must behave like distributed systems.

Recommended Practices

  • Correlation IDs

  • Structured logging (Serilog)

  • Module-level metrics

  • Centralized exception handling

app.Use(async (ctx, next) =>
{
    ctx.TraceIdentifier = Guid.NewGuid().ToString();
    await next();
});

This makes the transition to microservices painless.

Step 9: Extracting a Microservice (Real Migration)

Before

Orders Module → In-Process Interface → Catalog Module

After

Orders Service → HTTP/gRPC → Catalog Service

What Changes?

  • Interface implementation

  • Dependency registration

What Does NOT Change?

  • Business logic

  • Domain model

  • Application layer

This is the true payoff of the modular monolith.

Common Anti-Patterns to Avoid

  • Shared DbContext across modules

  • Cross-module entity references

  • Static helper classes

  • Tight coupling via shared utilities

  • Premature microservice extraction

Conclusion

Microservices are not a starting point—they are a scaling strategy.

ASP.NET Core enables teams to:

  • Build clean, modular monoliths

  • Delay distributed complexity

  • Scale into microservices safely and incrementally

A well-designed monolith is not technical debt—it is architectural leverage.