.NET Microservices Architecture with ASP.NET Core (.NET 10)

Last updated: September 29, 2026 · Tested with .NET 10 (SDK 10.0.201), MassTransit 8.5.10, Microsoft.Extensions.Http.Resilience 10.10, YARP 2.3

Short answer: a .NET microservices architecture is a set of small ASP.NET Core services, each owning one business capability and its own database, talking to each other through an API gateway for synchronous calls and a message broker for everything that can wait. The .NET stack for it in 2026 is minimal APIs, YARP for the gateway, the built-in resilience handler for HTTP calls, MassTransit 8 (still Apache-2.0) with the outbox pattern for messaging, OpenTelemetry for tracing, and Aspire 13 plus Kubernetes to run it. Below I build a three-service system on .NET 10, run it, kill one service, and show what the retries and circuit breaker actually do, including a 6.8-second checkout that is the best argument for events you will see.

And the honest caveat first: if you have three developers and one deployment a week, a well-modularised monolith will serve you better. Microservices trade in-process calls for network calls and one transaction for eventual consistency. That price is worth paying when several teams ship independently or one part of the system needs twenty times the compute of the rest. Earn the split; do not start with it.

What a Microservice Owns

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 and scaled on its own.
  • Owns its data. No other service reads its tables. When Orders needs catalog data it asks the Catalog API or subscribes to Catalog events, and yes, that means some duplication.
  • Communicates over the network: HTTP or gRPC when it needs an answer now, messages when it does not.
  • Can be rewritten or retired without touching the others.

Three Principles That Separate Microservices From a Distributed Monolith

1. Draw boundaries around business capabilities, not technical layers. “The data service” and “the business-logic service” must always change together, which is a monolith with network latency added. Use Domain-Driven Design’s bounded contexts: Orders owns order creation, order state and order history, data and API together.

2. Database per service. Shared tables mean a joint deployment for every schema change, which defeats the whole purpose. Give each service its own database, or at minimum its own schema with enforced isolation.

3. Design for failure, and prefer asynchronous communication for workflows. Networks time out and dependencies go down. Anything that does not need an immediate answer should be an event on a broker. The synchronous calls you cannot avoid get timeouts, retries and a circuit breaker. The demo below shows why in numbers.

The Demo: Orders, Notifications and a Gateway on .NET 10

Three projects plus a shared Contracts library, all created with dotnet new web -f net10.0. Orders stores orders in SQLite (so the demo runs anywhere; swap in Npgsql or SQL Server for real), calls Notifications over HTTP through a resilient typed client, and publishes an OrderPlaced event when a broker is configured. The gateway is YARP. The whole system is under 200 lines; the listings below are the complete files minus using lines and the EF entity classes.

The contract is the only code services share:

namespace Contracts;

public record OrderPlaced(Guid OrderId, string CustomerEmail, decimal TotalAmount, DateTime PlacedAtUtc);
public record NotificationRequest(Guid OrderId, string CustomerEmail);

Orders service: own database, resilient client, health check

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Http.Resilience;
using Polly;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<OrdersDbContext>(o => o.UseSqlite("Data Source=orders.db"));

// Typed client to Notifications with the standard pipeline:
// rate limiter -> total timeout -> retry -> circuit breaker -> per-attempt timeout
builder.Services.AddHttpClient<NotificationsClient>(c =>
        c.BaseAddress = new Uri(builder.Configuration["Services:Notifications"] ?? "http://localhost:5102"))
    .AddStandardResilienceHandler(o =>
    {
        o.Retry.MaxRetryAttempts = 3;
        o.Retry.Delay = TimeSpan.FromMilliseconds(200);
        o.Retry.BackoffType = DelayBackoffType.Exponential;
        o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
        o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(10);
        o.CircuitBreaker.MinimumThroughput = 4;
        o.CircuitBreaker.FailureRatio = 0.5;
        o.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(5);
        o.CircuitBreaker.OnOpened = _ => { Console.WriteLine("[Orders] circuit OPEN"); return default; };
        o.CircuitBreaker.OnClosed = _ => { Console.WriteLine("[Orders] circuit CLOSED again"); return default; };
    });

builder.Services.AddHealthChecks().AddDbContextCheck<OrdersDbContext>();

var app = builder.Build();
app.MapHealthChecks("/healthz");   // what Kubernetes probes

app.MapPost("/orders", async (CreateOrderRequest req, OrdersDbContext db, NotificationsClient notifications) =>
{
    var order = new Order { Id = Guid.NewGuid(), CustomerEmail = req.CustomerEmail,
                            TotalAmount = req.TotalAmount, PlacedAtUtc = DateTime.UtcNow };
    db.Orders.Add(order);
    await db.SaveChangesAsync();

    var notified = await notifications.SendConfirmationAsync(new NotificationRequest(order.Id, order.CustomerEmail));
    return Results.Created($"/orders/{order.Id}", new { order.Id, notified });
});

app.MapGet("/orders/{id:guid}", async (Guid id, OrdersDbContext db) =>
    await db.Orders.FindAsync(id) is { } o ? Results.Ok(o) : Results.NotFound());

app.Run();

public class NotificationsClient(HttpClient http)
{
    public async Task<bool> SendConfirmationAsync(NotificationRequest req)
    {
        try { return (await http.PostAsJsonAsync("/notifications", req)).IsSuccessStatusCode; }
        catch (Exception ex)   // HttpRequestException, TimeoutRejectedException, BrokenCircuitException
        {
            Console.WriteLine($"[Orders] notification gave up: {ex.GetType().Name}");
            return false;      // the order is already saved; a missing email must not fail checkout
        }
    }
}

Packages: Microsoft.EntityFrameworkCore.Sqlite, Microsoft.Extensions.Http.Resilience, Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore. That last one is easy to forget: AddDbContextCheck lives there, not in EF Core.

Notifications service

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();

app.MapHealthChecks("/healthz");
app.MapPost("/notifications", (NotificationRequest req) =>
{
    Console.WriteLine($"[Notifications] confirmation email queued for {req.CustomerEmail}");
    return Results.Accepted();
});
app.Run();

My first version of this file had MapHealthChecks without AddHealthChecks. It throws InvalidOperationException: Unable to find the required services at startup, the process dies, and from the Orders side that looks exactly like a network outage. Keep that in mind when reading the output below.

API gateway with YARP

Clients get one URL and never learn your topology. The gateway is four lines of code and a config section:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();
{
  "ReverseProxy": {
    "Routes": {
      "orders": { "ClusterId": "orders", "Match": { "Path": "/api/orders/{**rest}" },
                  "Transforms": [ { "PathRemovePrefix": "/api" } ] }
    },
    "Clusters": {
      "orders": { "Destinations": { "d1": { "Address": "http://localhost:5101/" } } }
    }
  }
}

Adding a service is a config change, not a recompile. YARP also forwards the W3C traceparent header, which is what makes distributed tracing work across the hop.

Running It: What Retries and a Circuit Breaker Really Do

I started all three services, placed orders through the gateway, killed Notifications, placed more, restarted it and placed two more. Real console output, times measured from the client:

== Notifications up ==
{"id":"4be62e24-...","notified":true}  (201)  took 577 ms   <- first request, JIT
{"id":"55a5292e-...","notified":true}  (201)  took 114 ms

== Notifications killed ==
{"id":"e4634c95-...","notified":false} (201)  took 6805 ms  <- 3 timeouts + backoff
{"id":"18b53faa-...","notified":false} (201)  took 92 ms    <- circuit open, fail fast
{"id":"8a2d428b-...","notified":false} (201)  took 97 ms

== Notifications restarted, 5 s break elapsed ==
{"id":"369c735e-...","notified":true}  (201)  took 576 ms
{"id":"ca64a3a5-...","notified":true}  (201)  took 98 ms
[Orders] order 4be62e24 saved, notification sent
[Orders] order 55a5292e saved, notification sent
[Orders] retry 1 after 198 ms: TimeoutRejectedException
[Orders] retry 2 after 303 ms: TimeoutRejectedException
[Orders] circuit OPEN
[Orders] retry 3 after 91 ms: TimeoutRejectedException
[Orders] notification gave up: BrokenCircuitException
[Orders] order e4634c95 saved, notification FAILED (order still saved)
[Orders] notification gave up: BrokenCircuitException
[Orders] order 18b53faa saved, notification FAILED (order still saved)
[Orders] notification gave up: BrokenCircuitException
[Orders] order 8a2d428b saved, notification FAILED (order still saved)
[Orders] circuit CLOSED again
[Orders] order 369c735e saved, notification sent
[Orders] order ca64a3a5 saved, notification sent

Four things this shows that the docs do not:

  • The first failure is expensive. Three 2-second attempt timeouts plus exponential backoff cost the customer 6.8 seconds on checkout, for an email. Retries protect you from blips, not from a dependency that is down.
  • The breaker opened on the third timeout (minimum throughput 4 within the sampling window, failure ratio above 0.5) and the last retry failed instantly with BrokenCircuitException. Every order after that took about 90 ms instead of 6.8 s.
  • Recovery is automatic. After the 5-second break, one half-open probe succeeded and the breaker closed. Nobody restarted Orders.
  • The order was saved every time. Because the notification call is wrapped and returns false, checkout never failed. That is the correct behaviour for a non-critical dependency, and it is also the tell that this call should not be synchronous at all.

Asynchronous Messaging With MassTransit 8 and the Outbox

The fix for the 6.8 seconds is to publish an event and let Notifications consume it when it is back. With MassTransit and RabbitMQ the Orders side becomes:

// dotnet add package MassTransit.RabbitMQ --version 8.*
// dotnet add package MassTransit.EntityFrameworkCore --version 8.*
builder.Services.AddMassTransit(x =>
{
    x.AddEntityFrameworkOutbox<OrdersDbContext>(o => { o.UseSqlite(); o.UseBusOutbox(); });
    x.UsingRabbitMq((ctx, cfg) => { cfg.Host("localhost"); cfg.ConfigureEndpoints(ctx); });
});

// in the endpoint, after db.Orders.Add(order):
await publisher.Publish(new OrderPlaced(order.Id, order.CustomerEmail, order.TotalAmount, order.PlacedAtUtc));
await db.SaveChangesAsync();   // outbox: the row and the message commit together

And the consumer in Notifications:

builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderPlacedConsumer>();
    x.UsingRabbitMq((ctx, cfg) => { cfg.Host("localhost"); cfg.ConfigureEndpoints(ctx); });
});

public class OrderPlacedConsumer : IConsumer<OrderPlaced>
{
    public Task Consume(ConsumeContext<OrderPlaced> ctx)
    {
        // At-least-once delivery: key your side effect on OrderId so a redelivery is a no-op.
        Console.WriteLine($"[Notifications] OrderPlaced {ctx.Message.OrderId} for {ctx.Message.CustomerEmail}");
        return Task.CompletedTask;
    }
}

The outbox matters more than the broker choice. Without it, a crash between SaveChangesAsync and Publish silently loses the event; with UseBusOutbox the message is written to an outbox table in the same transaction as the order and delivered afterwards. The DbContext needs three extra entities for that: mb.AddInboxStateEntity(); mb.AddOutboxMessageEntity(); mb.AddOutboxStateEntity(); in OnModelCreating.

This code compiles in the repo but I did not run it, because there is no RabbitMQ on the test machine. To try it: docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:4-management, set RabbitMq:Host to localhost, and the demo switches from the HTTP call to the event.

Licensing gotcha, September 2026: a plain dotnet add package MassTransit.RabbitMQ now pulls 9.2, which is commercial and requires a paid licence for deployed applications. MassTransit 8 stays Apache-2.0 and receives fixes through the end of 2026, so pin --version 8.* unless you have bought v9. The community fork OpenTransit is the other option if you need a free path after that.

gRPC for Internal Synchronous Calls

REST at the public edge, gRPC between trusted services when latency matters. It is contract-first, binary over HTTP/2, and generates typed clients from the .proto:

service Inventory {
  rpc CheckStock (StockRequest) returns (StockReply);
}
message StockRequest { string product_id = 1; }
message StockReply { bool in_stock = 1; int32 quantity = 2; }

Wrap gRPC calls in the same resilience thinking; the standard handler applies to HttpClient, and gRPC clients get retries through their channel configuration. See the gRPC in .NET guide for a working service.

Deploying: Containers, Kubernetes Probes, Aspire

Since the .NET 7 SDK you do not need a Dockerfile: dotnet publish /t:PublishContainer builds an image from the SDK. In Kubernetes, point the liveness probe at the health endpoint and give each service its own autoscaler, which is the entire reason you split them:

livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
resources:
  requests: { cpu: "250m", memory: "256Mi" }

For local development, Aspire 13 (the “.NET” prefix was dropped in version 13) orchestrates the services, RabbitMQ and databases with F5 debugging, and its dashboard shows the traces and logs across all of them. It is the fastest way to run this demo as one app. When you are ready for the cloud, the deploy .NET microservices to AKS post walks through the Kubernetes side.

Observability Is Not Optional

One user request will cross the gateway, Orders, the broker and Notifications. Without correlation you debug blind. Add OpenTelemetry on day one:

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSource("MassTransit")
        .AddOtlpExporter());

Export to the Aspire dashboard locally and to Jaeger, Grafana Tempo or Application Insights in production. The traceparent header you saw YARP forward is what ties the spans together.

Best Practices and Pitfalls

  • Keep services independently deployable. If two must always ship together, merge them.
  • Version contracts additively. Add fields; never remove or rename fields a consumer depends on.
  • Make consumers idempotent. Brokers deliver at least once; duplicates will happen.
  • Use the outbox. Otherwise you will lose events on the one crash that matters.
  • Protect every synchronous call with timeouts, retries and a breaker, and ask whether it should be a call at all.
  • Put an API gateway in front and handle auth and rate limiting there once, not in every service.
  • Expose health checks that include the database, so an orchestrator restarts a service that cannot reach its own data.
  • Secure internal traffic with mTLS or tokens. Zero trust applies inside the cluster.
  • Avoid the distributed monolith: long synchronous chains (A calls B calls C calls D) multiply latency and failure at every hop.
  • Never share a database. It is the most tempting shortcut and the most expensive.
  • No distributed transactions. Use sagas: a sequence of local transactions with compensating actions.
  • Start with two or three coarse services, not fifteen, and split only under real pressure from team ownership, scaling or release cadence.

FAQ

How do microservices communicate in .NET? Synchronously over HTTP or gRPC with a resilient HttpClient, and asynchronously through a broker such as RabbitMQ or Azure Service Bus using MassTransit. Prefer events for anything that does not need an immediate answer.

Is MassTransit still free? Version 8 is Apache-2.0 and maintained through 2026. Version 9, which NuGet installs by default, is commercial. Pin 8.*.

Do I need an API gateway? Yes, once you have more than one public-facing service. YARP is Microsoft’s reverse proxy for ASP.NET Core and is configured, not coded.

What is the difference between a retry and a circuit breaker? A retry repeats a failed call a few times with a delay. A circuit breaker stops calling a dependency that keeps failing, returns errors instantly for a while, then probes it. In the demo, retries cost 6.8 s; the breaker brought that down to 90 ms.

Should I use microservices for a new project? Usually not. Start with a modular monolith, keep bounded contexts clean inside it, and extract a service when a team or a scaling need demands it. The clean architecture in C# guide shows how to keep that monolith splittable.

Conclusion

Microservices in .NET are less about splitting code and more about designing services that own their data, fail without taking the neighbours down, and talk through contracts you can evolve. The demo made the trade-off measurable: a synchronous call to a dead dependency cost 6.8 seconds until the circuit breaker cut it to 90 milliseconds, and an event with an outbox would have cost nothing. Build the first split around one bounded context, give it its own database and pipeline, connect it with events, and measure before you split again.

References:Microsoft.Extensions.Http.Resilience · YARP · MassTransit transactional outbox · Aspire

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *