Learn ASP.NET Core microservices from scratch — architecture, gRPC, resilience, Docker and Kubernetes deployment. Start building production services today.
If you have been searching for a practical guide to ASP.NET Core microservices, you have probably found two extremes: marketing diagrams with boxes and arrows, or 40-file GitHub repos with no explanation. This tutorial sits in the middle. We will design, build and deploy a realistic microservices architecture in .NET 10, and — more importantly — explain why each decision matters when your system grows from three services to thirty.
Microservices are not free. They trade in-process method calls (fast, transactional, type-safe) for network calls (slow, unreliable, versioned). You only win that trade when the organisational benefit — independent deployment by independent teams — outweighs the distributed-systems tax. Keep that sentence in mind for the rest of this article.
When You Should (and Should Not) Use ASP.NET Core Microservices
Before writing a line of code, be honest about the problem. A well-structured modular monolith in ASP.NET Core will outperform a badly-split microservices estate on almost every metric: latency, cost, debuggability and developer onboarding time.
Choose microservices when at least two of these are true:
- Independent deploy cadence. The payments team ships daily; the reporting team ships monthly. Coupling them in one binary makes both slower.
- Divergent scaling profiles. Your image-processing workload needs 32 vCPU nodes; your CRUD API needs 0.5 vCPU and 200 replicas.
- Independent failure domains. A memory leak in the recommendations engine must not take checkout offline.
- Team autonomy at scale. Roughly five or more teams touching the same codebase daily.
If none apply, build a modular monolith with clean internal boundaries. You can always extract services later — and the boundaries you learn from production traffic will be far better than the ones you guess at on a whiteboard.
Finding Service Boundaries with Domain-Driven Design
The single biggest cause of microservices failure is wrong boundaries. Split by bounded context (Ordering, Catalog, Payments, Shipping), never by technical layer (a "Repository Service" or a "Validation Service"). A correct boundary owns its data end-to-end and can answer most requests without calling anyone else.
A practical smoke test: if adding a single business feature requires you to change and co-deploy three services, your boundaries are wrong. That is a distributed monolith — all of the pain, none of the benefit.
Building Your First Service: A Minimal API Blueprint
Let's build the Catalog service. Minimal APIs keep the startup path explicit and shave measurable milliseconds off cold start, which matters when Kubernetes is churning pods.
var builder = WebApplication.CreateBuilder(args);
// Service defaults: OpenTelemetry, health checks, resilient HTTP.
builder.AddServiceDefaults();
builder.Services.AddDbContext<CatalogDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("catalogdb")));
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapDefaultEndpoints(); // /health and /alive
var products = app.MapGroup("/api/products");
products.MapGet("/", async (CatalogDbContext db, int page = 1, int size = 20) =>
{
var items = await db.Products
.AsNoTracking()
.OrderBy(p => p.Id)
.Skip((page - 1) * size)
.Take(Math.Clamp(size, 1, 100))
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
return Results.Ok(items);
});
products.MapGet("/{id:int}", async (int id, CatalogDbContext db) =>
await db.Products.FindAsync(id) is { } product
? Results.Ok(new ProductDto(product.Id, product.Name, product.Price))
: Results.NotFound());
app.Run();
public record ProductDto(int Id, string Name, decimal Price);
Three details worth internalising. AsNoTracking() avoids populating EF Core's change tracker on read paths — on a list endpoint under load this is often a 20–30% throughput difference. Math.Clamp on page size stops a client from requesting 1,000,000 rows and taking the service down. And AddProblemDetails() gives every service a consistent RFC 9457 error shape, which is what makes debugging a chain of five services tolerable.
Database Per Service — The Rule You Cannot Break
Each service owns its schema. No other service may query those tables — not "just for a report", not "just this once". The moment two services share a table, you can no longer deploy them independently, and you have lost the only reason you adopted microservices.
The obvious objection: "then how do I join Orders to Customers?" You don't. You either replicate the small slice of customer data you need via events (eventual consistency), or you compose at the API gateway / BFF layer. Both are more work than a SQL join. That work is the price of independence.
Service-to-Service Communication: HTTP, gRPC and Messaging
Use synchronous calls when the caller genuinely cannot proceed without the answer (checkout needs a price). Use asynchronous messaging for everything else (send the confirmation email, update the recommendation index, decrement inventory).
For internal synchronous calls between .NET services, gRPC is typically 2–5× faster than JSON over HTTP/1.1, thanks to Protobuf's compact binary encoding and HTTP/2 multiplexing. Keep REST at the edge for browsers and third parties.
// Typed client registration with built-in resilience (.NET 8+)
builder.Services.AddHttpClient<PricingClient>(client =>
{
client.BaseAddress = new Uri("https+http://pricing");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(options =>
{
// Fail fast per attempt; the pipeline handles the retrying.
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
});
AddStandardResilienceHandler wires up a Polly v8 pipeline — rate limiter, total timeout, retry, circuit breaker, per-attempt timeout — in one line. Two settings deserve emphasis. Jitter prevents the thundering-herd problem where every instance retries at the same millisecond and re-kills the recovering service. The circuit breaker stops you from queueing thousands of doomed requests against a dead dependency, which is how a single failure cascades into a full outage.
Crucially, only retry idempotent operations. Retrying POST /payments can charge a customer twice. Send an idempotency key with every mutating request and have the receiver deduplicate on it.
Event-Driven Messaging and the Outbox Pattern
Here is the subtlest bug in microservices: you save an order to your database, then publish an OrderPlaced event to the broker. If the process crashes between those two steps, the order exists but nobody downstream knows. You cannot wrap a database and a message broker in one atomic transaction.
The fix is the transactional outbox: write the event into an outbox table inside the same transaction as the business data, then let a background publisher drain it.
public async Task<Guid> PlaceOrderAsync(OrderRequest request, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var order = Order.Create(request.CustomerId, request.Lines);
_db.Orders.Add(order);
// Same transaction => the event cannot be lost or orphaned.
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.CreateVersion7(),
Type = nameof(OrderPlaced),
Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.Total)),
OccurredOnUtc = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return order.Id;
}
A hosted service then polls the outbox and publishes. This gives at-least-once delivery, which means consumers must be idempotent — store processed message IDs and skip duplicates. Note Guid.CreateVersion7(): sequential GUIDs dramatically reduce index fragmentation compared with random v4 GUIDs on a hot insert table.
Sagas: Distributed Transactions Without Two-Phase Commit
With one database per service, a multi-service workflow (reserve inventory → charge card → schedule shipment) cannot be a single ACID transaction. Use a saga: a sequence of local transactions where each step publishes an event, and every step has a compensating action. If the card is declined, emit PaymentFailed and let Inventory release the reservation. Design compensations up front — retrofitting them after launch is how you end up with permanently stuck orders and manual database surgery at 3am.
API Gateway, Authentication and the Edge
Clients should not talk to twelve services directly. An API gateway gives you one entry point for TLS termination, routing, rate limiting, auth and request aggregation. In the .NET world, YARP is the natural choice because it is just middleware you can extend in C#.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = "api-gateway";
});
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapReverseProxy();
app.Run();
Validate the JWT at the gateway, then propagate identity inward. Inside the cluster, still authorise per service — a compromised pod should not have blanket access. That is the zero-trust principle: the network perimeter is not a security boundary.
Observability: You Cannot Debug What You Cannot See
In a monolith, a stack trace tells the whole story. In microservices, a single user action touches six processes, and you need distributed tracing to reconstruct it. OpenTelemetry is the standard, and it is first-class in .NET.
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeFormattedMessage = true;
o.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(builder.Environment.ApplicationName))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation())
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation())
.UseOtlpExporter(); // reads OTEL_EXPORTER_OTLP_ENDPOINT
Ship traces, metrics and logs to one backend (Jaeger, Grafana Tempo, Azure Monitor, Honeycomb). Then enforce structured logging — always log the correlation ID and business identifiers as properties, never interpolated into a string, so you can actually query them.
Deploying ASP.NET Core Microservices at Scale
Every service ships as a container. Use a multi-stage Dockerfile, run as non-root, and keep the runtime image minimal.
// Dockerfile
// FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
// WORKDIR /src
// COPY ["Catalog.Api/Catalog.Api.csproj", "Catalog.Api/"]
// RUN dotnet restore "Catalog.Api/Catalog.Api.csproj"
// COPY . .
// RUN dotnet publish "Catalog.Api/Catalog.Api.csproj" -c Release -o /app/publish
//
// FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
// WORKDIR /app
// COPY --from=build /app/publish .
// USER $APP_UID
// ENTRYPOINT ["dotnet", "Catalog.Api.dll"]
Chiseled images strip the shell and package manager: roughly 100 MB smaller and a far smaller CVE surface than the full Debian base. Copying the .csproj before the source code lets Docker cache the restore layer, so code-only changes rebuild in seconds.
On Kubernetes, wire your two health endpoints to the right probes. Liveness must not check dependencies — if your database blips and liveness fails, Kubernetes restarts every pod simultaneously and turns a brief outage into a total one.
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddNpgSql(connectionString, tags: ["ready"]);
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("live")
});
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("ready")
});
Set CPU/memory requests and limits on every pod, configure a HorizontalPodAutoscaler (or KEDA, if you want to scale on queue depth rather than CPU), and always run at least two replicas behind a PodDisruptionBudget so node maintenance never causes downtime.
Local Development with .NET Aspire
Running eight services, Postgres, Redis and RabbitMQ by hand is miserable. .NET Aspire models the whole topology in C# and gives you service discovery, wired-up connection strings and a live dashboard with traces.
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache");
var catalogDb = builder.AddPostgres("pg").AddDatabase("catalogdb");
var catalog = builder.AddProject<Projects.Catalog_Api>("catalog")
.WithReference(catalogDb);
builder.AddProject<Projects.Web_Bff>("bff")
.WithReference(catalog)
.WithReference(cache)
.WaitFor(catalog);
builder.Build().Run();
One dotnet run starts everything. Aspire also generates Kubernetes and Azure Container Apps manifests, so your local topology and production deployment stay honest with each other.
Common Pitfalls in .NET Microservices
- The distributed monolith. Services that must be deployed together. Fix the boundaries, not the pipeline.
- Shared databases. The fastest route to permanent coupling.
- A shared "Common" NuGet package with domain models. Every change forces a fleet-wide rebuild. Share plumbing (telemetry, auth handlers), never business types.
- Chatty calls. Ten sequential HTTP hops per request will destroy your p99. Batch, cache, or merge the services.
- Retries without jitter, timeouts or circuit breakers. Amplifies failures instead of absorbing them.
- Breaking API changes. Additive changes only; version the endpoint when you must break it. You never control when clients upgrade.
- No correlation IDs. Without them, production debugging is guesswork.
- Splitting too early. Start modular, extract when the pain is real and measured.
Conclusion: Key Takeaways
Done well, ASP.NET Core microservices give you independent deployment, targeted scaling and isolated failure domains. Done badly, they give you a slower monolith with network latency between its classes. The difference comes down to discipline in a handful of areas:
- Boundaries first. Model bounded contexts before you create a single project.
- Own your data. One database per service, no exceptions, no shared tables.
- Async by default. Reserve synchronous calls for answers the caller truly cannot continue without.
- Use the outbox. It is the only reliable way to keep state changes and events consistent.
- Assume failure. Timeouts, jittered retries and circuit breakers on every remote call.
- Instrument everything. OpenTelemetry traces are not optional in a distributed system.
- Automate delivery. Chiseled containers, correct probes, autoscaling and independent pipelines per service.
Start small: extract one genuinely independent service, give it its own database, add resilience and tracing, and deploy it on its own pipeline. Learn what breaks. Then extract the next one. That incremental path — rather than a big-bang rewrite — is how the most successful .NET teams arrive at a microservices architecture that actually scales.
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