Building Microservices with .NET: A Practical Guide
Build a real microservices system with .NET. Covers service decomposition, inter-service communication (REST + gRPC + messaging), API gateway, service discovery, distributed tracing, and when NOT to use microservices.
Before You Start: The Microservices Tax
Microservices are not free. Before adopting them, understand the cost:
- Network latency — every cross-service call adds 1–10ms and can fail
- Distributed transactions — no ACID across services, only eventual consistency
- Operational complexity — each service needs its own deployment pipeline, monitoring, scaling
- Debugging difficulty — a bug may span 4 services, requiring distributed tracing to find
- Data duplication — each service owns its data, cross-service queries require APIs or events
When microservices pay off:
- Different services need to scale independently (orders vs recommendations vs notifications)
- Different teams own different domains and need independent deployments
- Some services use different tech stacks (ML in Python, UI in Node)
- A true organisational boundary exists that maps to the service boundary
When to stay on a monolith:
- Team is < 10 engineers
- Domain isn't well understood yet
- Operational maturity isn't there
A modular monolith is often the right intermediate step.
Example System: OrderFlow
We'll build a simplified e-commerce backend with four services:
┌──────────────────────────────────────────────────────┐
│ API Gateway (YARP) │
│ https://api.orderflow.com │
└────────┬───────────┬───────────────┬──────────────────┘
│ │ │
/orders /products /notifications
│ │ │
┌────────▼──┐ ┌──────▼──┐ ┌───────▼────────┐
│ Order │ │ Product │ │ Notification │
│ Service │ │ Service │ │ Service │
│ :5001 │ │ :5002 │ │ :5003 │
└────────┬──┘ └──────────┘ └───────▲────────┘
│ │
└──── RabbitMQ ─────────────┘
(OrderCreated event)Project Structure
OrderFlow/
├── docker-compose.yml
├── Gateway/
│ └── OrderFlow.Gateway/ (YARP reverse proxy)
├── Services/
│ ├── OrderFlow.Orders/ (Order microservice)
│ ├── OrderFlow.Products/ (Product microservice)
│ └── OrderFlow.Notifications/ (Notification microservice)
└── Shared/
└── OrderFlow.Contracts/ (Shared message contracts)Shared Contracts
Message contracts should be in a shared library referenced by producer and consumer:
// OrderFlow.Contracts/Events/OrderCreated.cs
namespace OrderFlow.Contracts.Events;
public record OrderCreated(
Guid OrderId,
string CustomerId,
decimal Amount,
DateTime CreatedAt,
IReadOnlyList<OrderLine> Lines);
public record OrderLine(string ProductId, int Quantity, decimal UnitPrice);Order Service
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMq:Host"]);
cfg.ConfigureEndpoints(ctx);
});
});
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation().AddJaegerExporter());
var app = builder.Build();
app.MapControllers();
app.Run();// OrdersController.cs
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly OrderDbContext _db;
private readonly IPublishEndpoint _bus;
public OrdersController(OrderDbContext db, IPublishEndpoint bus)
{
_db = db;
_bus = bus;
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest req,
CancellationToken ct)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = req.CustomerId,
Lines = req.Lines.Select(l => new OrderLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice
}).ToList(),
CreatedAt = DateTime.UtcNow
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
// Publish event — consumers react asynchronously
await _bus.Publish(new OrderCreated(
order.Id, order.CustomerId,
order.Lines.Sum(l => l.Quantity * l.UnitPrice),
order.CreatedAt,
order.Lines.Select(l => new OrderLine(l.ProductId, l.Quantity, l.UnitPrice)).ToList()), ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, new { order.Id });
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var order = await _db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
return order is null ? NotFound() : Ok(order);
}
}Notification Service (Async Consumer)
// NotificationConsumer.cs
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
private readonly IEmailService _email;
private readonly ILogger<OrderCreatedConsumer> _logger;
public async Task Consume(ConsumeContext<OrderCreated> context)
{
_logger.LogInformation("Sending confirmation for Order {OrderId}", context.Message.OrderId);
await _email.SendOrderConfirmationAsync(
context.Message.CustomerId,
context.Message.OrderId,
context.CancellationToken);
}
}
// Registration
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMq:Host"]);
cfg.ReceiveEndpoint("notifications-order-created", e =>
{
e.ConfigureConsumer<OrderCreatedConsumer>(ctx);
e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(5)));
});
});
});Synchronous Inter-Service Communication (gRPC)
When Order Service needs product data synchronously (to validate stock before creating an order):
// In OrderFlow.Orders — typed gRPC client
builder.Services.AddGrpcClient<ProductService.ProductServiceClient>(options =>
options.Address = new Uri(builder.Configuration["Services:Products"]!));
// Use in order creation
public async Task<IActionResult> Create([FromBody] CreateOrderRequest req, CancellationToken ct)
{
// Validate products exist and have stock
foreach (var line in req.Lines)
{
var product = await _productClient.GetProductAsync(
new GetProductRequest { ProductId = line.ProductId }, cancellationToken: ct);
if (product.StockQuantity < line.Quantity)
return BadRequest($"Insufficient stock for {line.ProductId}");
}
// ... create order
}API Gateway with YARP
dotnet add package Yarp.ReverseProxy// Gateway Program.cs
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
app.MapReverseProxy();// appsettings.json
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "orders-cluster",
"Match": { "Path": "/orders/{**catch-all}" }
},
"products-route": {
"ClusterId": "products-cluster",
"Match": { "Path": "/products/{**catch-all}" }
}
},
"Clusters": {
"orders-cluster": {
"Destinations": {
"orders-1": { "Address": "http://orders-service:5001/" }
}
},
"products-cluster": {
"Destinations": {
"products-1": { "Address": "http://products-service:5002/" }
}
}
}
}
}Docker Compose
# docker-compose.yml
services:
gateway:
build: ./Gateway/OrderFlow.Gateway
ports: ["8080:8080"]
depends_on: [orders, products, notifications]
orders:
build: ./Services/OrderFlow.Orders
environment:
- ConnectionStrings__Orders=Server=sqlserver;Database=Orders;...
- RabbitMq__Host=amqp://rabbitmq
- Services__Products=http://products:5002
depends_on: [sqlserver, rabbitmq]
products:
build: ./Services/OrderFlow.Products
environment:
- ConnectionStrings__Products=Server=sqlserver;Database=Products;...
notifications:
build: ./Services/OrderFlow.Notifications
environment:
- RabbitMq__Host=amqp://rabbitmq
depends_on: [rabbitmq]
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong@Passw0rd
rabbitmq:
image: rabbitmq:3-management
ports: ["15672:15672"]
jaeger:
image: jaegertracing/all-in-one
ports: ["16686:16686"]Health Checks Across Services
// Each service
builder.Services.AddHealthChecks()
.AddSqlServer(builder.Configuration.GetConnectionString("Orders")!)
.AddRabbitMQ(builder.Configuration["RabbitMq:Host"]!);
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});Distributed Tracing
Add OpenTelemetry to every service. The TraceId propagates in HTTP headers automatically.
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(builder.Environment.ApplicationName))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddSource("MassTransit")
.AddOtlpExporter(o => o.Endpoint = new Uri("http://jaeger:4317")));The Saga Pattern for Distributed Transactions
When an order requires: reserve stock → charge payment → confirm order. Any step can fail.
// MassTransit Saga State Machine
public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
public OrderStateMachine()
{
InstanceState(x => x.CurrentState);
Event(() => OrderPlaced, x => x.CorrelateById(ctx => ctx.Message.OrderId));
Event(() => StockReserved, x => x.CorrelateById(ctx => ctx.Message.OrderId));
Event(() => PaymentCharged, x => x.CorrelateById(ctx => ctx.Message.OrderId));
Event(() => StockReserveFailed, x => x.CorrelateById(ctx => ctx.Message.OrderId));
Initially(
When(OrderPlaced)
.Then(ctx => ctx.Saga.CustomerId = ctx.Message.CustomerId)
.TransitionTo(AwaitingStockReservation)
.Publish(ctx => new ReserveStock(ctx.Saga.CorrelationId, ctx.Message.Lines)));
During(AwaitingStockReservation,
When(StockReserved)
.TransitionTo(AwaitingPayment)
.Publish(ctx => new ChargePayment(ctx.Saga.CorrelationId, ctx.Message.Amount)),
When(StockReserveFailed)
.TransitionTo(Failed)
.Publish(ctx => new OrderFailed(ctx.Saga.CorrelationId, "Insufficient stock")));
// ... more states
}
public State AwaitingStockReservation { get; private set; } = null!;
public State AwaitingPayment { get; private set; } = null!;
public State Confirmed { get; private set; } = null!;
public State Failed { get; private set; } = null!;
public Event<OrderPlaced> OrderPlaced { get; private set; } = null!;
public Event<StockReserved> StockReserved { get; private set; } = null!;
public Event<StockReserveFailed> StockReserveFailed { get; private set; } = null!;
public Event<PaymentCharged> PaymentCharged { get; private set; } = null!;
}Interview Questions
Q: What is the biggest mistake teams make when adopting microservices? Adopting them too early — before the team understands the domain well enough to draw correct service boundaries. Wrong boundaries create chatty services that make synchronous calls to each other for every operation, eliminating the independence that microservices are meant to provide.
Q: How do microservices handle data consistency without distributed transactions? Through eventual consistency — each service owns its data and publishes events when it changes. Other services react to those events asynchronously. The Saga pattern coordinates multi-step workflows with compensating actions for failures. Strong consistency across services requires careful design and is rarely needed.
Q: What is the purpose of an API gateway? Single entry point for all clients — handles routing to downstream services, authentication, rate limiting, SSL termination, and request/response transformation. Clients don't need to know about individual service addresses.
Q: When should two microservices communicate synchronously vs asynchronously? Synchronous (REST/gRPC): when the result is needed to complete the current request (validate stock, get product details). Asynchronous (message queue): when the action doesn't affect the current response (send email, update analytics, trigger a follow-up workflow).
Q: What is a Saga and why is it needed? A pattern for coordinating a distributed transaction across multiple services. Since you can't use a single ACID transaction across service boundaries, a Saga breaks it into local transactions with compensating actions for failures. Choreography (events) and orchestration (state machine) are the two approaches.
RabbitMQ & Messaging Knowledge Check
5 questions · Test what you just learned · Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.