
Learn microservices architecture in ASP.NET Core — design, build, and deploy scalable .NET microservices with code examples. Start building today.
Microservices architecture has become the default answer for teams that need to ship features fast without stepping on each other's toes. Instead of one giant ASP.NET Core application where every deployment is a high-stakes event, you build a set of small, independently deployable services — each owning its own data, its own release cycle, and its own failure boundary. In this guide, you'll learn how to design a microservices architecture in ASP.NET Core, build two communicating services with runnable C# code, and deploy them at scale with Docker and Kubernetes. More importantly, you'll learn why each decision matters, because microservices done wrong are far more painful than a well-organized monolith.
What Is Microservices Architecture (and When Should You Use It)?
Microservices architecture is an approach to building software as a collection of small, autonomous services, each focused on a single business capability. An e-commerce system might have an Orders service, a Catalog service, a Payments service, and a Notifications service. Each one:
- Runs in its own process and is deployed independently
- Owns its own database — no other service touches its tables
- Communicates over the network via HTTP/gRPC or asynchronous messaging
- Can be scaled, rewritten, or even retired without touching the others
The honest trade-off: you're exchanging in-process method calls for network calls, and one database transaction for eventual consistency. That's a real cost. The rule of thumb most experienced .NET architects follow is: start with a well-modularized monolith, and split into microservices when team size or scaling requirements force you to. If you have three developers and one deployment a week, a monolith will almost certainly serve you better. If you have six teams shipping daily and one service needs 20x the compute of the others, microservices pay for themselves.
Designing Microservices in ASP.NET Core: Core Principles
1. Draw Boundaries Around Business Capabilities, Not Technical Layers
The single most common failure mode is splitting by technical layer ("the data service", "the business logic service"). That creates chatty services that must all change together — a distributed monolith. Instead, use Domain-Driven Design's bounded contexts: each service owns a complete vertical slice of one business capability. The Orders service owns order creation, order state, and order history — data, logic, and API together.
2. Database per Service
Each service gets its own database (or at minimum its own schema with enforced isolation). If two services share tables, you can never change one's schema without coordinating a joint deployment — which defeats the entire purpose. When Service A needs Service B's data, it asks B's API or subscribes to B's events. Yes, this means duplicating some data. That duplication is the price of independence, and it's usually worth paying.
3. Prefer Asynchronous Communication for Workflows
Synchronous HTTP calls between services create temporal coupling: if Payments is down, Orders can't complete checkout. For anything that doesn't need an immediate answer, publish an event to a message broker (RabbitMQ, Azure Service Bus, Kafka) and let consumers process it when they're ready. Reserve synchronous calls for genuine request/response needs — and protect them with timeouts and retries.
Building ASP.NET Core Microservices: A Practical Example
Let's build the skeleton of a real system: an Orders service that publishes an OrderPlaced event, and a Notifications service that consumes it. We'll use .NET 8 minimal APIs and RabbitMQ via MassTransit, the most widely used messaging library in the .NET ecosystem.
The Orders Service
Create the project and add packages:
// dotnet new webapi -n Orders.Api --use-minimal-apis
// dotnet add package MassTransit.RabbitMQ
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
Define the event contract in a shared library (contracts are the only thing services should share):
namespace Contracts;
public record OrderPlaced(
Guid OrderId,
string CustomerEmail,
decimal TotalAmount,
DateTime PlacedAtUtc);
Wire up the service in Program.cs:
using Contracts;
using MassTransit;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrdersDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("OrdersDb")));
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMq:Host"] ?? "localhost");
cfg.ConfigureEndpoints(context);
});
});
var app = builder.Build();
app.MapPost("/orders", async (
CreateOrderRequest request,
OrdersDbContext db,
IPublishEndpoint publisher) =>
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerEmail = request.CustomerEmail,
TotalAmount = request.TotalAmount,
Status = OrderStatus.Placed,
PlacedAtUtc = DateTime.UtcNow
};
db.Orders.Add(order);
await db.SaveChangesAsync();
await publisher.Publish(new OrderPlaced(
order.Id, order.CustomerEmail, order.TotalAmount, order.PlacedAtUtc));
return Results.Created($"/orders/{order.Id}", new { order.Id });
});
app.Run();
public record CreateOrderRequest(string CustomerEmail, decimal TotalAmount);
Why publish an event instead of calling the Notifications API directly? Because tomorrow you'll add a Loyalty service and an Analytics service that also care about new orders. With events, they simply subscribe — the Orders service never changes. That's the open/closed principle applied to system architecture.
The Notifications Service (Consumer)
using Contracts;
using MassTransit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMq:Host"] ?? "localhost");
cfg.ConfigureEndpoints(context);
});
});
var app = builder.Build();
app.Run();
public class OrderPlacedConsumer : IConsumer<OrderPlaced>
{
private readonly ILogger<OrderPlacedConsumer> _logger;
public OrderPlacedConsumer(ILogger<OrderPlacedConsumer> logger)
=> _logger = logger;
public async Task Consume(ConsumeContext<OrderPlaced> context)
{
var order = context.Message;
_logger.LogInformation(
"Sending confirmation for order {OrderId} to {Email}",
order.OrderId, order.CustomerEmail);
// In production: call your email provider here.
await Task.CompletedTask;
}
}
Run RabbitMQ locally with one command — docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management — start both services, POST an order, and watch the Notifications service log the event. You now have two independently deployable services communicating asynchronously.
Add an API Gateway with YARP
Clients shouldn't know your internal topology. An API gateway gives them one URL and handles routing, auth, and rate limiting in one place. Microsoft's YARP (Yet Another Reverse Proxy) makes this trivial in ASP.NET Core:
// dotnet add package Yarp.ReverseProxy
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
Routes live in appsettings.json, mapping /api/orders/* to the Orders cluster and /api/catalog/* to Catalog. Because it's config-driven, adding a new service never requires recompiling the gateway.
Make Synchronous Calls Resilient
When you do need service-to-service HTTP, never make a naked call. .NET 8's built-in resilience handler (built on Polly) adds retries, timeouts, and a circuit breaker in three lines:
// dotnet add package Microsoft.Extensions.Http.Resilience
builder.Services.AddHttpClient<CatalogClient>(client =>
client.BaseAddress = new Uri("https://catalog-api"))
.AddStandardResilienceHandler();
Why a circuit breaker? When a downstream service is dying, hammering it with retries makes things worse and ties up your own threads. The breaker fails fast, gives the sick service room to recover, and prevents one failure from cascading across the whole system.
Deploying ASP.NET Core Microservices at Scale
Containerize with Docker
Since .NET 8, you don't even need a Dockerfile — dotnet publish /t:PublishContainer builds an optimized image. If you prefer an explicit Dockerfile, use the standard multi-stage pattern with the mcr.microsoft.com/dotnet/aspnet:8.0 runtime image and run as a non-root user.
Orchestrate with Kubernetes
Kubernetes handles the operational heavy lifting: restarting crashed containers, rolling deployments, service discovery, and autoscaling. A minimal deployment for the Orders service:
// orders-deployment.yaml (YAML, shown here for completeness)
// apiVersion: apps/v1
// kind: Deployment
// metadata: { name: orders-api }
// spec:
// replicas: 3
// template:
// spec:
// containers:
// - name: orders-api
// image: registry.example.com/orders-api:1.4.0
// resources:
// requests: { cpu: "250m", memory: "256Mi" }
// livenessProbe:
// httpGet: { path: /healthz, port: 8080 }
Back that liveness probe with ASP.NET Core health checks:
builder.Services.AddHealthChecks()
.AddDbContextCheck<OrdersDbContext>();
app.MapHealthChecks("/healthz");
Pair a HorizontalPodAutoscaler with CPU or queue-depth metrics and each service scales independently — the whole reason you split them in the first place. For local development, .NET Aspire is now the best-in-class way to orchestrate multiple services, RabbitMQ, and databases on your machine with F5 debugging, then generate deployment manifests for production.
Observability Is Non-Negotiable
In a monolith, a stack trace tells the whole story. In microservices, one user request may touch five services, and without correlation you're debugging blind. Add OpenTelemetry from day one:
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("MassTransit")
.AddOtlpExporter());
Export to Jaeger, Grafana Tempo, or Application Insights, and every request gets a distributed trace that follows it across service and queue boundaries.
Best Practices and Common Pitfalls in Microservices Architecture
- Do: keep services independently deployable — if two services must always ship together, merge them.
- Do: version your event contracts additively; never remove or rename fields consumers depend on.
- Do: use the outbox pattern (MassTransit has built-in support) so saving the order and publishing the event succeed or fail together — otherwise a crash between
SaveChangesAsyncandPublishsilently loses events. - Do: make consumers idempotent; message brokers guarantee at-least-once delivery, so duplicates will happen.
- Don't: build a distributed monolith — long synchronous call chains (A calls B calls C calls D) multiply latency and failure probability at every hop.
- Don't: share a database between services, ever. It's the most tempting shortcut and the most expensive one.
- Don't: reach for distributed transactions. Use sagas — a sequence of local transactions with compensating actions — for workflows that span services.
- Don't: start with 15 services. Start with two or three coarse-grained ones and split further only when a real pressure (team ownership, scaling, release cadence) demands it.
Conclusion: Key Takeaways
Microservices architecture in ASP.NET Core is a powerful tool for scaling both your system and your organization — but it's a trade, not a free upgrade. You gain independent deployment, targeted scaling, and team autonomy; you pay in network complexity, eventual consistency, and operational overhead. The .NET ecosystem in 2026 makes that price lower than ever: minimal APIs for lean services, MassTransit for messaging, YARP for gateways, built-in Polly resilience, OpenTelemetry for tracing, and .NET Aspire plus Kubernetes for orchestration.
Key takeaways:
- Split by business capability (bounded contexts), never by technical layer.
- Give every service its own database and communicate through APIs and events.
- Prefer asynchronous messaging; protect unavoidable synchronous calls with retries and circuit breakers.
- Use the outbox pattern and idempotent consumers to make messaging reliable.
- Instrument everything with OpenTelemetry before you need it, not after.
- Earn your microservices: start coarse, split under real pressure.
Clone the pattern above, swap the console log for a real email provider, and you have the foundation of a production-grade microservices architecture. Start small, measure everything, and let genuine scaling pain — not hype — drive every split.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment