arp
// OrderService.Data/OrderDbContext.cs
public class OrderDbContext : DbContext
{
public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderOutboxMessage> OutboxMessages => Set<OrderOutboxMessage>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.HasDefaultSchema("orders");
builder.Entity<OrderOutboxMessage>()
.HasKey(m => m.Id);
builder.Entity<OrderOutboxMessage>()
.Property(m => m.ProcessedAt).IsRequired(false);
}
}
**Rationale:** Schema isolation prevents implicit coupling. The `OrderOutboxMessage` table enables the Outbox pattern without external dependencies during the initial write phase.
### Step 2: Implement the Outbox Pattern for Reliable Messaging
Synchronous message publishing fails when the database commits but the broker drops the message. The Outbox pattern writes the domain event and the outbox record in the same transaction.
```csharp
// OrderService.Application/Orders/CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderDto>
{
private readonly OrderDbContext _context;
private readonly IMediator _mediator;
public CreateOrderCommandHandler(OrderDbContext context, IMediator mediator)
{
_context = context;
_mediator = mediator;
}
public async Task<OrderDto> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order(request.CustomerId, request.Items);
_context.Orders.Add(order);
// Publish domain event to outbox within same transaction
var @event = new OrderCreatedEvent(order.Id, order.CustomerId, order.Total);
_context.OutboxMessages.Add(new OrderOutboxMessage
{
Id = Guid.NewGuid(),
Type = @event.GetType().AssemblyQualifiedName!,
Content = JsonSerializer.Serialize(@event),
CreatedAt = DateTime.UtcNow
});
await _context.SaveChangesAsync(ct);
return order.ToDto();
}
}
A background worker polls unprocessed outbox records and publishes them via MassTransit. This decouples transaction commit from broker delivery.
Step 3: Orchestrate Distributed Transactions with the Saga Pattern
Distributed workflows require compensation logic. The Choreography Saga (event-driven) scales better than Orchestration for .NET microservices when services maintain independent lifecycles.
// PaymentService.Consumers/OrderCreatedConsumer.cs
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
private readonly IPaymentRepository _payments;
private readonly IBus _bus;
public OrderCreatedConsumer(IPaymentRepository payments, IBus bus)
{
_payments = payments;
_payments = payments;
_bus = bus;
}
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var payment = new Payment(context.Message.OrderId, context.Message.Amount);
await _payments.AddAsync(payment);
try
{
await _payments.ProcessAsync(payment.Id);
await _bus.Publish(new PaymentSucceededEvent(payment.OrderId));
}
catch (PaymentException)
{
await _bus.Publish(new PaymentFailedEvent(payment.OrderId));
// Saga compensates: OrderService rolls back
}
}
}
Architecture decision: Use choreography for 2-4 service workflows. Switch to orchestration (state machines, temporal workflows) when compensation logic exceeds three services or requires human approval steps.
Step 4: Apply Resilience Policies with Polly
Network calls require explicit failure handling. Default HttpClient fails fast and does not recover gracefully.
// OrderService.Infrastructure/HttpClient/ResiliencePipelineFactory.cs
public static class ResiliencePipelineFactory
{
public static ResiliencePipeline CreateHttpPipeline() =>
new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
BackoffType = DelayBackoffType.Exponential,
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
UseJitter = true
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
HandledExceptions = [typeof(HttpRequestException)],
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10,
BreakDuration = TimeSpan.FromSeconds(15)
})
.AddTimeout(TimeSpan.FromSeconds(10))
.Build();
}
Register via IHttpClientFactory:
builder.Services.AddHttpClient("inventory-service")
.AddResilienceHandler("default", ResiliencePipelineFactory.CreateHttpPipeline);
Rationale: Exponential backoff with jitter prevents thundering herd. Circuit breakers isolate downstream failures. Timeout policies prevent thread pool exhaustion. Polly's unified pipeline replaces fragmented retry decorators.
Step 5: Instrument with OpenTelemetry for Distributed Tracing
Microservices without correlation IDs are unmanageable in production. Propagate trace context across HTTP, gRPC, and messaging.
// OrderService.Program.cs
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddMassTransitInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
MassTransit and HttpClient automatically propagate traceparent headers. Ensure all consumers extract and continue the span.
Pitfall Guide
1. Shared Databases Across Services
Mistake: Multiple services querying the same relational schema to avoid duplication.
Impact: Schema changes require coordinated deployments. Transaction isolation levels conflict. Lock contention spikes during peak load.
Best Practice: Enforce database-per-service. Use read models, CQRS, or materialized views for cross-service data needs. Accept eventual consistency for queries.
2. Synchronous Chaining for Business Workflows
Mistake: Service A calls B, B calls C, C calls D over HTTP for a single business operation.
Impact: Latency compounds. A single downstream failure cascades. Timeouts and retries create partial state.
Best Practice: Decompose workflows into independent steps. Use saga patterns with compensation. Publish events instead of blocking calls.
3. Ignoring Idempotency in Event Consumers
Mistake: Processing the same message twice due to broker redelivery or consumer restart.
Impact: Duplicate charges, inventory over-allocation, corrupted state.
Best Practice: Implement idempotency keys. Check processed message registries before applying side effects. Use database constraints or distributed locks for critical paths.
4. Treating Microservices as Deployment Units Only
Mistake: Splitting a monolith into projects without redesigning data boundaries or communication contracts.
Impact: Services remain tightly coupled. Deployments stay coordinated. Scaling provides no isolation benefit.
Best Practice: Define bounded contexts first. Align services to domain capabilities, not technical layers. Validate coupling through dependency graphs before extraction.
5. Premature Service Mesh Adoption
Mistake: Deploying Istio/Linkerd before standardizing application-level resilience and observability.
Impact: Operational overhead multiplies. Debugging requires mesh + app + network layers. Team velocity drops.
Best Practice: Implement resilience at the application layer first (Polly, Outbox, OpenTelemetry). Add service mesh only when multi-language environments or advanced traffic splitting justify the complexity.
6. Hardcoded Retry Policies Without Jitter
Mistake: Fixed-interval retries across all services during downstream degradation.
Impact: Thundering herd amplifies failure. Recovery time extends by 3-5x.
Best Practice: Use exponential backoff with random jitter. Cap maximum attempts. Implement circuit breakers to halt retries during sustained failure.
7. Missing Distributed Tracing Context Propagation
Mistake: HTTP/gRPC calls do not forward trace headers. Messaging consumers start new traces.
Impact: Incidents require log correlation across services. MTTR increases. Performance bottlenecks remain invisible.
Best Practice: Propagate traceparent/tracestate automatically via framework instrumentation. Validate context continuity in integration tests.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput event processing | Event-Driven + Outbox + MassTransit | Decouples producers/consumers, enables horizontal scaling | Medium infrastructure, low dev cost |
| Strict consistency required | Synchronous gRPC + Saga compensation | Maintains ACID boundaries while allowing rollback | High latency tolerance, moderate infra |
| Legacy monolith extraction | Strangler Fig + API Gateway | Enables incremental migration without big-bang rewrite | Low risk, phased investment |
| Team size < 5, single domain | Modular monolith | Reduces distributed complexity until scale demands separation | Lowest infra cost, fastest delivery |
| Multi-cloud / hybrid deployment | .NET Aspire + Sidecar pattern | Standardizes local/cloud dev, abstracts service discovery | Medium learning curve, high portability |
Configuration Template
// appsettings.json
{
"ConnectionStrings": {
"OrderDb": "Host=localhost;Database=orders;Username=postgres;Password=dev",
"RabbitMq": "amqp://guest:guest@localhost"
},
"Resilience": {
"RetryMaxAttempts": 3,
"CircuitBreakerFailureRatio": 0.5,
"TimeoutSeconds": 10
},
"OpenTelemetry": {
"Endpoint": "http://localhost:4317",
"ServiceName": "order-service"
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("OrderDb")));
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host(builder.Configuration.GetConnectionString("RabbitMq"));
cfg.ConfigureEndpoints(context);
});
});
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddMassTransitInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
builder.Services.AddHttpClient("inventory")
.AddResilienceHandler("default", ResiliencePipelineFactory.CreateHttpPipeline);
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.MapPost("/orders", async (CreateOrderCommand cmd, IMediator mediator) =>
Results.Ok(await mediator.Send(cmd)));
app.Run();
Quick Start Guide
- Initialize Aspire Host: Run
dotnet new aspire-apphost -n MicroservicesAppHost to create a unified orchestration project for local development.
- Add Service Projects: Create
OrderService and PaymentService as Minimal API projects. Reference them in the AppHost via builder.AddProject<OrderService>("orderservice").
- Configure Messaging: Install
MassTransit.RabbitMQ and MassTransit.EntityFrameworkCore. Register consumers and outbox tables in Program.cs. Run dotnet ef migrations add Initial and dotnet ef database update.
- Launch & Validate: Execute
dotnet run in the AppHost directory. Aspire launches services, RabbitMQ, and a dashboard. Verify trace propagation by sending a POST to /orders and checking the OpenTelemetry collector logs.
Microservices succeed when patterns align with distributed reality. Enforce data ownership, prefer asynchronous communication, implement explicit resilience, and instrument continuously. The .NET ecosystem provides the primitives; disciplined application of these patterns delivers production-grade autonomy.