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