Microservices Architecture with .NET: Design and Implementation
# Microservices with .NET
Microservices can unlock team autonomy and independent scaling, but only when service boundaries and operational discipline are clear. In distributed systems I've built, the difference between a successful microservices adoption and a painful one always came down to preparation — understanding why you're decomposing, not just how.
Design Fundamentals
Service Boundaries
The single most important decision in microservices architecture is where you draw the lines. A poorly bounded service creates more coupling than the monolith it replaced.
In distributed systems I've built, the biggest regret teams have is splitting too early. Start with a well-structured monolith, identify the seams, then extract only when there's a clear operational or organizational reason.
Bounded Contexts and Domain-Driven Design
Microservice boundaries should align with DDD bounded contexts. Each service encapsulates a cohesive domain model:
When two services need the same concept (e.g., "Product"), each maintains its own representation. The Order Service might only need `ProductId`, `Name`, and `Price`, while the Inventory Service needs `ProductId`, `SKU`, `WarehouseLocation`, and `StockCount`.
API Gateway Pattern
An API Gateway sits between clients and your services, handling cross-cutting concerns:
class=class="code-string">"code-comment">// Program.cs — YARP-based API Gateway
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection(class="code-string">"ReverseProxy"));
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(class="code-string">"default", opt =>
{
opt.PermitLimit = class="code-number">100;
opt.Window = TimeSpan.FromMinutes(class="code-number">1);
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapReverseProxy();
app.Run();Inter-Service Communication Patterns
Choosing the right communication pattern is critical. There is no single best option — each has trade-offs that matter in different contexts.
HTTP/REST
The simplest option. Works well for synchronous request-response flows where you need an immediate answer.
Pros: Universal tooling, easy debugging, human-readable payloads
Cons: Tight coupling between caller and callee, cascading failures, higher latency
class=class="code-string">"code-comment">// Typed HTTP client with resilience policies
builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>(client =>
{
client.BaseAddress = new Uri(class="code-string">"https:class="code-commentclass="code-string">">//order-service:class="code-number">5001");
client.Timeout = TimeSpan.FromSeconds(class="code-number">10);
})
.AddStandardResilienceHandler();gRPC
Binary protocol built on HTTP/2. Ideal for internal service-to-service calls where performance matters.
Pros: Strong contracts via .proto files, streaming support, ~10x faster serialization than JSON
Cons: Not browser-friendly without gRPC-Web, harder to debug, requires proto management
class=class="code-string">"code-comment">// inventory.proto
syntax = class="code-string">"proto3";
option csharp_namespace = class="code-string">"InventoryService.Grpc";
service InventoryGrpc {
rpc CheckStock (StockRequest) returns (StockResponse);
rpc ReserveItems (ReserveRequest) returns (ReserveResponse);
rpc StreamStockUpdates (StockSubscription) returns (stream StockUpdate);
}
message StockRequest {
string product_id = class="code-number">1;
string warehouse_id = class="code-number">2;
}
message StockResponse {
string product_id = class="code-number">1;
int32 available_quantity = class="code-number">2;
bool is_available = class="code-number">3;
}
message ReserveRequest {
string order_id = class="code-number">1;
repeated ReserveItem items = class="code-number">2;
}
message ReserveItem {
string product_id = class="code-number">1;
int32 quantity = class="code-number">2;
}
message ReserveResponse {
bool success = class="code-number">1;
string reservation_id = class="code-number">2;
string failure_reason = class="code-number">3;
}
message StockSubscription {
repeated string product_ids = class="code-number">1;
}
message StockUpdate {
string product_id = class="code-number">1;
int32 new_quantity = class="code-number">2;
string timestamp = class="code-number">3;
}class=class="code-string">"code-comment">// gRPC server implementation
public class InventoryGrpcService : InventoryGrpc.InventoryGrpcBase
{
private readonly IInventoryRepository _repository;
private readonly ILogger<InventoryGrpcService> _logger;
public InventoryGrpcService(
IInventoryRepository repository,
ILogger<InventoryGrpcService> logger)
{
_repository = repository;
_logger = logger;
}
public override async Task<StockResponse> CheckStock(
StockRequest request, ServerCallContext context)
{
var stock = await _repository.GetStockAsync(
request.ProductId, request.WarehouseId);
return new StockResponse
{
ProductId = request.ProductId,
AvailableQuantity = stock?.Quantity ?? class="code-number">0,
IsAvailable = (stock?.Quantity ?? class="code-number">0) > class="code-number">0
};
}
}Asynchronous Messaging with RabbitMQ
For workflows where you don't need an immediate response, message queues decouple services in both time and availability. This is the pattern I reach for most often in production systems.
class=class="code-string">"code-comment">// RabbitMQ consumer using MassTransit
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
private readonly IInventoryRepository _inventory;
private readonly ILogger<OrderCreatedConsumer> _logger;
public OrderCreatedConsumer(
IInventoryRepository inventory,
ILogger<OrderCreatedConsumer> logger)
{
_inventory = inventory;
_logger = logger;
}
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var order = context.Message;
_logger.LogInformation(
class="code-string">"Processing inventory reservation for Order {OrderId}", order.OrderId);
try
{
foreach (var item in order.Items)
{
await _inventory.ReserveStockAsync(
item.ProductId, item.Quantity, order.OrderId);
}
await context.Publish(new InventoryReservedEvent
{
OrderId = order.OrderId,
ReservedAt = DateTime.UtcNow
});
}
catch (InsufficientStockException ex)
{
_logger.LogWarning(ex,
class="code-string">"Insufficient stock for Order {OrderId}", order.OrderId);
await context.Publish(new InventoryReservationFailedEvent
{
OrderId = order.OrderId,
Reason = ex.Message
});
}
}
}
class=class="code-string">"code-comment">// MassTransit registration
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(class="code-string">"rabbitmq", class="code-string">"/", h =>
{
h.Username(class="code-string">"guest");
h.Password(class="code-string">"guest");
});
cfg.ReceiveEndpoint(class="code-string">"inventory-order-created", e =>
{
e.ConfigureConsumer<OrderCreatedConsumer>(ctx);
e.UseMessageRetry(r => r.Intervals(
TimeSpan.FromSeconds(class="code-number">1),
TimeSpan.FromSeconds(class="code-number">5),
TimeSpan.FromSeconds(class="code-number">15)));
});
});
});Pattern Comparison Summary
| Aspect | HTTP/REST | gRPC | Async Messaging |
|---|---|---|---|
| Coupling | High | High | Low |
| Latency | Medium | Low | Variable |
| Reliability | Requires retries | Requires retries | Built-in durability |
| Debugging | Easy | Moderate | Harder |
| Best for | External APIs | Internal high-perf | Event-driven workflows |
Monolith to Microservices Migration
Every successful microservices project I've worked on started as a monolith migration, not a greenfield build. Here is a battle-tested strategy.
Phase 1: Prepare the Monolith
Before extracting anything, restructure internally. Introduce module boundaries within the monolith that mirror your future service boundaries.
Phase 2: Strangler Fig Extraction
Extract one service at a time, starting with the least coupled domain. Route traffic through a facade that decides whether to call the monolith or the new service.
Phase 3: Data Migration
This is the hardest part. Each service must own its data store.
Phase 4: Cut Over
Containerization and Orchestration
Docker Configuration
A well-structured Dockerfile uses multi-stage builds to keep production images minimal:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["OrderService/OrderService.csproj", "OrderService/"]
RUN dotnet restore "OrderService/OrderService.csproj"
COPY . .
WORKDIR "/src/OrderService"
RUN dotnet publish -c Release -o /app/publish --no-restore
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser
USER appuser
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "OrderService.dll"]Docker Compose for Local Development
# docker-compose.yml
version: "3.8"
services:
order-service:
build:
context: .
dockerfile: OrderService/Dockerfile
ports:
- "5001:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__OrderDb=Host=order-db;Database=orders;Username=postgres;Password=postgres
- RabbitMq__Host=rabbitmq
depends_on:
order-db:
condition: service_healthy
rabbitmq:
condition: service_healthy
inventory-service:
build:
context: .
dockerfile: InventoryService/Dockerfile
ports:
- "5002:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__InventoryDb=Host=inventory-db;Database=inventory;Username=postgres;Password=postgres
- RabbitMq__Host=rabbitmq
depends_on:
inventory-db:
condition: service_healthy
rabbitmq:
condition: service_healthy
payment-service:
build:
context: .
dockerfile: PaymentService/Dockerfile
ports:
- "5003:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__PaymentDb=Host=payment-db;Database=payments;Username=postgres;Password=postgres
- RabbitMq__Host=rabbitmq
depends_on:
payment-db:
condition: service_healthy
rabbitmq:
condition: service_healthy
order-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- order-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
inventory-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: inventory
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- inventory-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
payment-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- payment-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "5672:5672"
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
volumes:
order-data:
inventory-data:
payment-data:Health Checks and Resilience
Every microservice must expose health information. Without it, orchestrators cannot make intelligent routing or restart decisions.
class=class="code-string">"code-comment">// Program.cs — Health check configuration
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString(class="code-string">"OrderDb")!,
name: class="code-string">"postgresql",
tags: new[] { class="code-string">"db", class="code-string">"ready" })
.AddRabbitMQ(
new Uri(class="code-string">"amqp:class="code-commentclass="code-string">">//guest:guest@rabbitmq:class="code-number">5672"),
name: class="code-string">"rabbitmq",
tags: new[] { class="code-string">"messaging", class="code-string">"ready" })
.AddCheck<OrderProcessingHealthCheck>(
class="code-string">"order-processing",
tags: new[] { class="code-string">"custom", class="code-string">"ready" });
var app = builder.Build();
class=class="code-string">"code-comment">// Liveness: is the process alive?
app.MapHealthChecks(class="code-string">"/health/live", new HealthCheckOptions
{
Predicate = _ => false class=class="code-string">"code-comment">// No checks, just confirms the app responds
});
class=class="code-string">"code-comment">// Readiness: can this service handle traffic?
app.MapHealthChecks(class="code-string">"/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains(class="code-string">"ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});class=class="code-string">"code-comment">// Custom health check implementation
public class OrderProcessingHealthCheck : IHealthCheck
{
private readonly IOrderRepository _repository;
public OrderProcessingHealthCheck(IOrderRepository repository)
{
_repository = repository;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stuckOrders = await _repository.GetStuckOrdersCountAsync(
TimeSpan.FromMinutes(class="code-number">30), cancellationToken);
if (stuckOrders > class="code-number">50)
return HealthCheckResult.Unhealthy(
$class="code-string">"{stuckOrders} orders stuck in processing for >class="code-number">30 min");
if (stuckOrders > class="code-number">10)
return HealthCheckResult.Degraded(
$class="code-string">"{stuckOrders} orders stuck in processing");
return HealthCheckResult.Healthy();
}
}Distributed Tracing and Observability
In a monolith, a stack trace tells you everything. In microservices, a single user request can traverse five services — and without distributed tracing, debugging is nearly impossible.
OpenTelemetry Integration
class=class="code-string">"code-comment">// Program.cs — OpenTelemetry setup
builder.Services.AddOpenTelemetry()
.ConfigureResource(res => res
.AddService(
serviceName: class="code-string">"OrderService",
serviceVersion: class="code-string">"class="code-number">1.0.class="code-number">0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation()
.AddSource(class="code-string">"MassTransit")
.AddOtlpExporter(opt =>
{
opt.Endpoint = new Uri(class="code-string">"http:class="code-commentclass="code-string">">//otel-collector:class="code-number">4317");
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(class="code-string">"OrderService.Metrics")
.AddOtlpExporter(opt =>
{
opt.Endpoint = new Uri(class="code-string">"http:class="code-commentclass="code-string">">//otel-collector:class="code-number">4317");
}));Structured Logging with Correlation
class=class="code-string">"code-comment">// Serilog configuration with trace correlation
builder.Host.UseSerilog((context, config) => config
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty(class="code-string">"ServiceName", class="code-string">"OrderService")
.Enrich.WithSpanId()
.Enrich.WithTraceId()
.WriteTo.Console(new JsonFormatter())
.WriteTo.Seq(class="code-string">"http:class="code-commentclass="code-string">">//seq:class="code-number">5341"));What to Measure
The three pillars of observability each serve a different purpose:
In distributed systems I've built, the single most valuable dashboard shows the p99 latency per service endpoint alongside the error rate. When those two lines diverge, you have a problem forming before it becomes an outage.
Common Microservice Mistakes
After working on multiple microservices migrations, these are the patterns I see teams fall into repeatedly.
1. Distributed Monolith
You split the codebase into services but every request still requires synchronous calls through five of them in sequence. You have all the complexity of microservices with none of the independence. If you can't deploy Service A without also deploying Service B, they are not independent services.
2. Premature Decomposition
Splitting before you understand the domain boundaries leads to wrong service cuts. It is far harder to merge two services than to split one. Start with a modular monolith and extract when you have strong evidence for the boundary.
3. Shared Database
Two services reading from the same database table defeats the entire purpose. Changes to the schema become coordinated deployments. Each service must own its data, even if that means some duplication.
4. Ignoring Data Consistency
Distributed transactions (two-phase commit) across services don't work at scale. Embrace eventual consistency, implement the Saga pattern for multi-step workflows, and design compensating actions for failures.
5. No Contract Testing
Integration tests that spin up all services are slow, flaky, and don't run in CI reliably. Use consumer-driven contract tests (Pact, for example) to verify that services agree on API shapes without needing all services running.
6. Undersized Operational Investment
Microservices require serious infrastructure: CI/CD per service, centralized logging, distributed tracing, health checks, alerting, secrets management. If you don't have the platform maturity for this, microservices will slow you down.
7. Too Many Services Too Fast
I've seen teams create 30 services in the first quarter. Each one needs its own CI pipeline, monitoring, on-call rotation awareness, and documentation. Start with 2-3 extractions, build the platform capabilities, then accelerate.
When Microservices Make Sense
Not every system benefits from microservices. They are justified when:
They are not justified when:
Conclusion
Microservices are an organizational and architectural decision, not a default technical upgrade. The technology — .NET, Docker, RabbitMQ, gRPC — is the easier part. The hard part is getting the boundaries right, investing in observability, and building the operational discipline to run dozens of independent services in production. Choose microservices when the business context truly requires them, and invest in the platform before you invest in the decomposition.
I can help assess readiness and define a low-risk migration path for your system.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Clean Architecture in .NET: Building Scalable Project Structure
Apply Clean Architecture in .NET projects. A guide to layers, dependency management, and testable code.
.NET CI/CD: Automated Deployment with GitHub Actions and Azure DevOps
Design CI/CD pipelines for .NET projects. GitHub Actions, Azure DevOps, and deployment strategies.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch