Skip to main content

ASP.NET Core Microservices: Build & Deploy at Scale

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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...