Microservices architecture with .NET structures a system as a set of small, independently deployable services, each owning one business capability and its data, communicating over the network. It is written for architects and senior developers who are designing or rescuing a distributed .NET system and want practical, current guidance. This guide covers decomposition by business capability, data ownership, synchronous and asynchronous communication, API gateways, service discovery, sagas and the transactional outbox, observability, deployment and CI/CD, Microsoft's eShop reference application built on Aspire, and, just as importantly, when not to use microservices.
What Is Microservices Architecture?#
A microservice is an autonomous unit of deployment built around a business capability, such as catalog, ordering or payments. Each service has its own codebase, release cadence and data store, and it is owned by one team. Services collaborate through well-defined APIs and messages, never through each other's databases.
The benefits are organizational as much as technical. Teams can release independently without coordinating a big-bang deployment. Services scale independently, so a busy catalog does not force you to scale payments. Failures can be isolated behind timeouts and circuit breakers. Each service can choose the storage and libraries that fit its job.
The price is that you now run a distributed system. Every call can fail or time out, data is duplicated and eventually consistent, debugging needs distributed tracing, and deployment, security and versioning multiply by the number of services. Microservices trade code complexity for operational complexity, and that trade only pays off when team scale or independent scaling demands it.
How Microservices Work in .NET#
A typical .NET microservices system has a handful of recurring parts, and the .NET ecosystem now offers a first-party option for most of them.
| Concern | Role | Common .NET choice |
|---|---|---|
| Service implementation | Business logic and APIs | ASP.NET Core Minimal APIs, gRPC, worker services |
| Local orchestration | Run services and dependencies together | Aspire AppHost |
| Service discovery | Resolve logical names to endpoints | Microsoft.Extensions.ServiceDiscovery, Kubernetes DNS |
| Edge | Routing, authentication, aggregation | YARP, Azure API Management |
| Synchronous calls | Request/response between services | HttpClient with resilience, gRPC |
| Asynchronous messaging | Events and commands between services | Azure Service Bus, RabbitMQ or Kafka via Wolverine, MassTransit or NServiceBus |
| Resilience | Retries, timeouts, circuit breakers | Microsoft.Extensions.Http.Resilience (Polly-based) |
| Observability | Traces, metrics, logs | OpenTelemetry, the Aspire dashboard, Azure Monitor |
| Hosting | Run containers in production | Azure Container Apps, Kubernetes, App Service |
Aspire, previously branded .NET Aspire, ties much of this together. Its own description is a code-first toolchain for building, running and deploying distributed applications. An AppHost project declares services and infrastructure in C#, and a shared ServiceDefaults project gives every service the same telemetry, health checks, service discovery and resilience configuration. The Aspire guide covers it in depth.
Getting Started: The eShop Reference Application#
Microsoft's eShop repository is the best place to see these pieces working together. It targets .NET 10, uses Aspire for orchestration, and is split into services such as Catalog.API, Basket.API, Ordering.API, Identity.API and Webhooks.API, plus OrderProcessor and PaymentProcessor background services and web and mobile clients. The ordering service uses a DDD-style domain model (Ordering.Domain, Ordering.Infrastructure) with MediatR pipeline behaviors for logging, validation and transactions. Services communicate over HTTP, gRPC (the basket) and a RabbitMQ event bus, and a PostgreSQL server hosts a separate database per service.
The AppHost reads almost like an architecture diagram. The following is condensed from eShop's AppHost:
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("redis");
var rabbitMq = builder.AddRabbitMQ("eventbus").WithLifetime(ContainerLifetime.Persistent);
var postgres = builder.AddPostgres("postgres")
.WithImage("ankane/pgvector")
.WithImageTag("latest")
.WithLifetime(ContainerLifetime.Persistent);
var catalogDb = postgres.AddDatabase("catalogdb");
var orderDb = postgres.AddDatabase("orderingdb");
var basketApi = builder.AddProject<Projects.Basket_API>("basket-api")
.WithReference(redis)
.WithReference(rabbitMq).WaitFor(rabbitMq);
var catalogApi = builder.AddProject<Projects.Catalog_API>("catalog-api")
.WithReference(rabbitMq).WaitFor(rabbitMq)
.WithReference(catalogDb);
var orderingApi = builder.AddProject<Projects.Ordering_API>("ordering-api")
.WithReference(rabbitMq).WaitFor(rabbitMq)
.WithReference(orderDb).WaitFor(orderDb)
.WithHttpHealthCheck("/health");
builder.AddProject<Projects.OrderProcessor>("order-processor")
.WithReference(rabbitMq).WaitFor(rabbitMq)
.WithReference(orderDb)
.WaitFor(orderingApi);
builder.AddYarp("mobile-bff")
.WithExternalHttpEndpoints()
.ConfigureMobileBffRoutes(catalogApi, orderingApi); // an eShop helper extension
builder.Build().Run();Each service then calls builder.AddServiceDefaults() and maps MapDefaultEndpoints(). WithReference flows connection strings and service endpoints into configuration, and WaitFor delays startup until a dependency is healthy. The same AppHost in eShop also registers an Azure Container Apps environment for deployment.
Decomposing Services by Business Capability#
The most consequential decision is where to draw service boundaries, and the best guide is the business, not the technology. Decompose by business capability, meaning what the organization does (manage the catalog, take orders, collect payments, ship parcels), and align each service with a bounded context from Domain-Driven Design. Microsoft's microservices guidance makes the same connection: each bounded context correlates to one business microservice.
Useful heuristics:
- A service should own a complete business capability, including its rules and data, so most changes touch one service.
- Chatty boundaries are wrong boundaries. If two services must call each other for every request, merge them or move the responsibility.
- Transactions reveal boundaries. Data that must change atomically belongs in one service.
- Team ownership is a constraint, not an afterthought. One team can own several services; one service should not have several owning teams.
Avoid entity services, such as a CustomerService or ProductService that only wrap CRUD over one table. They create chatty, tightly coupled graphs in which every business operation spans several services. Also avoid nano-services so small that the network overhead exceeds the logic.
Data Ownership: Database per Service#
Each service must be the only writer and reader of its data. Other services get data through its API or by subscribing to its events, never through shared tables. This is what makes independent deployment possible: a service can change its schema without coordinating a release with five other teams.
Ownership is logical, not necessarily physical. eShop runs one PostgreSQL server with separate catalogdb, identitydb, orderingdb and webhooksdb databases. Separate schemas with separate credentials can also work. What matters is that no service can query another's tables.
The consequences need design. Data is duplicated: the ordering service keeps a snapshot of product names and prices at order time, updated from catalog events where appropriate. Cross-service queries are answered by composing APIs in a backend for frontend, or by building read models from events. Reporting usually moves to a separate analytics store fed by events or change data capture.
Synchronous vs Asynchronous Communication#
Synchronous request/response over HTTP or gRPC is simple and immediate, but it couples services in time: if the callee is down or slow, the caller suffers too. Asynchronous messaging decouples services in time at the cost of eventual consistency and more infrastructure. Mature systems use both, deliberately.
| Aspect | Synchronous (HTTP, gRPC) | Asynchronous (messages, events) |
|---|---|---|
| Temporal coupling | Both sides must be available | Producer and consumer run independently |
| Response | Immediate result or error | Processed later; results arrive as further events |
| Failure handling | Timeouts, retries, circuit breakers | Retries, dead-letter queues, idempotent consumers |
| Consistency | Immediate within the call | Eventual |
| Best for | Queries, user-facing request paths, gateway aggregation | State change notifications, workflows, integration |
| Main risk | Cascading failures and latency chains | Duplicate or out-of-order messages, harder debugging |
Prefer asynchronous events for propagating state changes ("order placed") and reserve synchronous calls for queries where the caller genuinely needs an answer now. Keep synchronous call chains short: a request that fans out through four services in sequence inherits the worst latency and availability of all four.
var builder = WebApplication.CreateBuilder(args);
// Service discovery, standard resilience, OpenTelemetry and health checks for every client.
builder.AddServiceDefaults();
// "https+http" prefers an HTTPS endpoint and falls back to HTTP.
builder.Services.AddHttpClient<CatalogClient>(client =>
client.BaseAddress = new Uri("https+http://catalog-api"));
// gRPC for a low-latency internal call, as eShop's web front end does for the basket.
builder.Services.AddGrpcClient<Basket.BasketClient>(options =>
options.Address = new Uri("http://basket-api"));
builder.Services.AddDbContext<OrderingDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("orderingdb")));
builder.EnrichNpgsqlDbContext<OrderingDbContext>(); // Aspire health checks and telemetry
var app = builder.Build();
app.MapDefaultEndpoints();
app.Run();
public sealed class CatalogClient(HttpClient http)
{
public Task<CatalogItem?> GetItemAsync(int id, CancellationToken ct) =>
http.GetFromJsonAsync<CatalogItem>($"api/catalog/items/{id}", ct);
}The Aspire ServiceDefaults template adds AddStandardResilienceHandler() to every HttpClient. That standard pipeline combines a rate limiter, a total request timeout, retries, a circuit breaker and a per-attempt timeout, which covers most transient failures without custom Polly code. See the resilience guide for tuning.
API Gateways and Backends for Frontends#
Letting clients call dozens of internal services directly couples them to your decomposition, multiplies round trips and widens the attack surface. An API gateway is a reverse proxy at the edge that handles routing, TLS termination, authentication, rate limiting and sometimes response aggregation. Microsoft's guidance recommends several gateways segregated by client type, the backend for frontend (BFF) pattern, over one giant gateway that turns into a bottleneck, which that guidance likens to a new enterprise service bus.
In .NET, YARP is the natural choice for a code-first gateway. It is a library, so the gateway is an ordinary ASP.NET Core application with its own middleware, authentication and observability. eShop's mobile BFF is a YARP resource declared in the AppHost. For managed features such as developer portals, subscription keys and policy management, Azure API Management is the common alternative.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddServiceDiscoveryDestinationResolver(); // Microsoft.Extensions.ServiceDiscovery.Yarp
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultEndpoints();
app.MapReverseProxy();
app.Run();{
"ReverseProxy": {
"Routes": {
"catalog": {
"ClusterId": "catalog",
"Match": { "Path": "/catalog/{**catch-all}" },
"Transforms": [ { "PathRemovePrefix": "/catalog" } ]
},
"orders": {
"ClusterId": "ordering",
"AuthorizationPolicy": "default",
"Match": { "Path": "/orders/{**catch-all}" }
}
},
"Clusters": {
"catalog": { "Destinations": { "d1": { "Address": "https+http://catalog-api" } } },
"ordering": { "Destinations": { "d1": { "Address": "https+http://ordering-api" } } }
}
}
}Keep business logic out of the gateway. It should route, secure and shape traffic; once it starts applying pricing rules or orchestrating workflows, it has become a distributed monolith's hub. The YARP guide covers transforms, load balancing and health checks.
Service Discovery#
Services need to find each other without hard-coded addresses. Microsoft.Extensions.ServiceDiscovery, used by Aspire, resolves logical names such as catalog-api to endpoints. Locally, Aspire injects the endpoints as configuration (Services:catalog-api:https:0); in Kubernetes or Azure Container Apps, platform DNS usually resolves the name. The scheme syntax https+http://catalog-api asks for an HTTPS endpoint first and falls back to HTTP, and https://_grpc.catalog-api would select a named endpoint. Because resolution happens inside HttpClient, application code stays identical across environments.
Distributed Data Management: Sagas and the Outbox#
Without shared databases there are no distributed ACID transactions, so business processes that span services must be modeled explicitly.
A saga is a sequence of local transactions, one per service, where each step publishes an event or command that triggers the next, and failures trigger compensating transactions that semantically undo earlier steps. In choreography, services react to each other's events with no central coordinator; it is simple for short flows but hard to follow as they grow. In orchestration, a coordinator tells participants what to do; it is easier to reason about and monitor but adds a component. The Azure Architecture Center also distinguishes compensable steps, a pivot step after which the saga can no longer be undone, and retryable steps after the pivot that must eventually succeed.
public enum FulfillmentState { AwaitingStock, AwaitingPayment, Completed, Cancelled }
// Orchestrated saga for one order. It is persisted between messages, keyed by OrderId,
// with a concurrency token; each handler runs in a transaction that also writes the
// returned commands to the outbox.
public sealed class OrderFulfillmentSaga
{
public Guid OrderId { get; private set; }
public decimal Amount { get; private set; }
public FulfillmentState State { get; private set; } = FulfillmentState.AwaitingStock;
public object[] Handle(StockReserved e)
{
State = FulfillmentState.AwaitingPayment;
return [new ChargePayment(OrderId, Amount)];
}
public object[] Handle(StockRejected e)
{
State = FulfillmentState.Cancelled;
return [new CancelOrder(OrderId, "Out of stock")];
}
public object[] Handle(PaymentSucceeded e) // the pivot: after this, move forward only
{
State = FulfillmentState.Completed;
return [new ConfirmOrder(OrderId)];
}
public object[] Handle(PaymentFailed e) // compensate the steps that already succeeded
{
State = FulfillmentState.Cancelled;
return [new ReleaseStock(OrderId), new CancelOrder(OrderId, "Payment failed")];
}
}Every saga step has the same hidden problem: a service must update its database and publish a message, and those are two systems that cannot share a transaction. If the process crashes between the two, either the event is lost or it announces a change that never committed. The transactional outbox solves this by writing the message to an outbox table in the same local transaction as the business change, and letting a relay publish it afterward. eShop implements this idea with its IntegrationEventLogEF project, which tracks each integration event through NotPublished, InProgress, Published and PublishedFailed states.
public sealed class OutboxMessage
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Type { get; init; }
public required string Payload { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public DateTimeOffset? PublishedAt { get; set; }
}
// In the command handler: the order and its event commit atomically.
db.Orders.Add(order);
db.Outbox.Add(new OutboxMessage
{
Type = nameof(OrderPlacedIntegrationEvent),
Payload = JsonSerializer.Serialize(new OrderPlacedIntegrationEvent(order.Id, order.Total)),
OccurredAt = clock.GetUtcNow()
});
await db.SaveChangesAsync(ct);
// The relay publishes pending messages at least once.
public sealed class OutboxRelay(IServiceScopeFactory scopes, IMessagePublisher publisher,
ILogger<OutboxRelay> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await PublishPendingAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Outbox relay failed; retrying on the next tick.");
}
}
}
private async Task PublishPendingAsync(CancellationToken ct)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrderingDbContext>();
var pending = await db.Outbox
.Where(m => m.PublishedAt == null)
.OrderBy(m => m.OccurredAt)
.Take(100)
.ToListAsync(ct);
foreach (var message in pending)
{
// The message ID travels with the message so consumers can deduplicate.
await publisher.PublishAsync(message.Type, message.Payload, message.Id, ct);
message.PublishedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
}
}
}Because a crash can happen after publishing but before marking the row, delivery is at least once, and every consumer must be idempotent, typically by recording processed message IDs in its own database (an inbox). If you run several relay instances, lock rows or elect a leader. Messaging frameworks such as Wolverine, NServiceBus and MassTransit provide production-grade outbox and inbox implementations; note that MassTransit version 9 is a commercial product, while earlier versions remain available under an open source license. The messaging guide compares brokers and frameworks.
Observability: Logs, Metrics and Traces#
In a distributed system, a single user action produces work in many processes, so you need correlated telemetry to understand it. OpenTelemetry is the standard: ASP.NET Core, HttpClient, gRPC and most database and messaging clients emit traces and metrics, and W3C trace context headers propagate the trace across service boundaries. The Aspire ServiceDefaults template wires up logging, metrics and tracing, and exports them over OTLP whenever OTEL_EXPORTER_OTLP_ENDPOINT is configured, which is how the Aspire dashboard receives data locally.
Add your own business telemetry on top, using the same names in every environment:
public sealed class OrderingTelemetry
{
public const string Name = "Shop.Ordering";
public static readonly ActivitySource Source = new(Name);
private readonly Counter<long> _ordersPlaced;
public OrderingTelemetry(IMeterFactory meterFactory)
{
var meter = meterFactory.Create(Name);
_ordersPlaced = meter.CreateCounter<long>("shop.orders.placed", unit: "{order}",
description: "Orders placed successfully");
}
public void OrderPlaced(string channel) =>
_ordersPlaced.Add(1, new KeyValuePair<string, object?>("shop.channel", channel));
}
// Program.cs
builder.Services.AddSingleton<OrderingTelemetry>();
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(OrderingTelemetry.Name))
.WithMetrics(metrics => metrics.AddMeter(OrderingTelemetry.Name));
// In a handler
using var activity = OrderingTelemetry.Source.StartActivity("PlaceOrder");
activity?.SetTag("shop.order_id", order.Id);Health checks complete the picture. The ServiceDefaults template maps /health for readiness and /alive for liveness, but only in development, because exposing health endpoints publicly has security implications; in production, expose them on an internal port or protect them. The OpenTelemetry guide covers exporters, sampling and log correlation.
Deployment and CI/CD#
Independent deployability is the point of microservices, so each service needs its own pipeline, version and release cadence. The .NET SDK can build OCI container images without a Dockerfile, which keeps per-service pipelines short:
name: ordering-api
on:
push:
branches: [main]
paths: ["src/Ordering.*/**", "tests/Ordering.*/**"]
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- run: dotnet test tests/Ordering.UnitTests
# Authenticate to your registry first, for example with docker/login-action.
- run: >
dotnet publish src/Ordering.API -c Release -t:PublishContainer
-p:ContainerRegistry=${{ vars.REGISTRY }}
-p:ContainerRepository=shop/ordering-api
-p:ContainerImageTag=${{ github.sha }}A few practices make independent deployment safe. Version APIs and message contracts, and keep changes backward compatible so producers and consumers can deploy in any order. Use consumer-driven contract tests for critical integrations. Roll out gradually with health-checked rolling or canary deployments, and make database migrations expand-then-contract so old and new versions can run side by side. Azure Container Apps suits most .NET microservices with less operational effort than Kubernetes, and eShop's AppHost targets it; AKS or another Kubernetes distribution makes sense when you need its ecosystem and have the platform team to run it.
Best Practices#
- Start from business capabilities and bounded contexts, not from layers or tables.
- Give every service exclusive ownership of its data, and share data only through APIs and events.
- Prefer events for state propagation and keep synchronous call chains short.
- Use the transactional outbox and idempotent consumers for every message that matters.
- Standardize cross-cutting concerns once, with a ServiceDefaults-style shared project for telemetry, resilience, health checks and discovery.
- Instrument everything with OpenTelemetry and propagate trace context through messages as well as HTTP.
- Automate per-service pipelines with backward-compatible contracts and gradual rollouts.
- Run on .NET 10 LTS, since .NET 8 and .NET 9 reach end of support on November 10, 2026, and budget time to evaluate .NET 11 when it ships in November 2026.
Common Pitfalls#
The distributed monolith. Services that must deploy together, share a database or call each other synchronously for every request have all the costs of microservices and none of the benefits.
Shared libraries with domain logic. A common NuGet package containing entities couples every service's release cycle. Share contracts and infrastructure helpers, not domain models.
Dual writes. Saving to the database and then publishing to a broker without an outbox loses or invents events whenever a process crashes at the wrong moment.
No correlation. Without trace propagation, incidents turn into log archaeology across a dozen services.
Premature decomposition. Splitting before the domain is understood fixes the wrong boundaries in network contracts, where they are expensive to move.
When Not to Use Microservices#
Microservices are a solution to organizational scaling problems. If you do not have those problems, they add cost without benefit. Avoid them when a single team owns the product, when the domain is still being discovered, when you lack automated deployment and observability, or when most operations need strong consistency across the whole data model. A modular monolith gives you most of the design benefits, such as clear boundaries, separate schemas and in-process events, while deploying as one unit, and it can be split later along proven seams.
| Criterion | Monolith | Modular monolith | Microservices |
|---|---|---|---|
| Team size | One team | One to several teams | Several autonomous teams |
| Deployment | One unit | One unit | One per service |
| Data | Shared schema | Schema per module | Database per service |
| Consistency | ACID everywhere | ACID within and across modules | ACID within a service, eventual across |
| Operational cost | Low | Low to medium | High |
| Independent scaling | No | No | Yes |
| Best when | Small product, early stage | Growing product, evolving domain | Large organization, proven boundaries, uneven load |
Frequently Asked Questions#
How big should a microservice be?#
Size a service by business capability and team ownership, not by lines of code. A service should be small enough for one team to understand and change confidently, and large enough that most business operations complete within it without chatty calls to other services.
Should microservices share a database?#
No. Each service should be the only one that reads and writes its data, even if several logical databases live on one physical server, as they do in eShop. Share data through APIs and events so that each service can change its schema independently.
Should services communicate with REST, gRPC or messaging?#
Use asynchronous messaging for state changes and workflows, because it decouples services in time. Use HTTP or gRPC for queries that need an immediate answer; gRPC suits low-latency internal calls, while HTTP APIs suit external and browser-facing traffic.
Do I need Kubernetes to run .NET microservices?#
No. Azure Container Apps, App Service and other managed container platforms run .NET microservices with far less operational overhead. Kubernetes is worth it when you need its ecosystem and have a platform team to operate it.
What is the role of Aspire in a microservices system?#
Aspire orchestrates services and their dependencies during development, provides a dashboard for telemetry, and standardizes service discovery, resilience, health checks and OpenTelemetry through its ServiceDefaults project. It also offers publishing support for deployment targets such as Azure Container Apps.
Summary#
- Microservices align independently deployable services with business capabilities and bounded contexts.
- Each service owns its data; share it through APIs and events, never through tables.
- Prefer events for state changes, keep synchronous chains short, and rely on the standard resilience handler.
- Use gateways and BFFs at the edge, service discovery inside, and sagas plus the transactional outbox for cross-service workflows.
- OpenTelemetry, per-service pipelines and backward-compatible contracts make the system operable.
- Study eShop and Aspire for a working reference, and choose a modular monolith when you lack the organizational need for microservices.