Microservices Architecture (.NET/C#)
Microservices get adopted for the wrong reasons more often than any other architecture I’ve worked with. This post is for .NET engineers and leads deciding whether to split an application apart — and, just as importantly, whether to leave it whole. My bias, stated up front: start with a monolith and decompose only when a concrete pain forces your hand. The rest of this covers what microservices actually are, the trade-offs you take on, and the .NET resilience patterns you’ll need once you’re committed.
What are microservices?
Microservices is an architectural style where a large application is decomposed into small, independent services that communicate over the network. Each service owns its data, deploys independently, and focuses on a specific business capability.
Traditional Monolith: One application, one database, one deployment
Microservices: Many small applications, many databases, independent deployments
When to use them
Good fit:
- Large teams working on different features (each team owns a service)
- Services have vastly different scaling needs (search service needs 10 instances, auth needs 2)
- Services are built with different technology stacks
- You need independent deployment cycles (feature team ships weekly, payment team ships monthly)
Not a good fit:
- Small teams or startups (operational overhead is high)
- Services are tightly coupled or share data constantly
- You don’t have observability and monitoring infrastructure
Rule of thumb: Start with a monolith. Decompose to microservices only when a specific pain point demands it (scaling, team coordination, deployment frequency).
Key characteristics
- Independently deployable: Service A can deploy without affecting Service B
- Loosely coupled: Services communicate via APIs or message queues, not shared databases
- Data isolation: Each service owns its database; no cross-service queries
- Failure isolation: Service A failing doesn’t crash Service B (but plan for it to be unavailable)
- Autonomous teams: One team owns end-to-end: code, test, deploy, monitor
Challenges (Be Aware)
- Network latency and failures - Remote calls fail; local calls never do
- Data consistency - No transactions across services; embrace eventual consistency
- Operational complexity - Monitoring, logging, and debugging are harder across services
- Testing complexity - Integration testing requires spinning up multiple services
Building Microservices in .NET
Project Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Create API service
dotnet new webapi -f net6.0 -lang "C#" -au "Windows"
# Create unit test project
dotnet new xunit -f net6.0 -lang "C#"
# Add to solution
dotnet sln add Software.API/Software.API.csproj
# Add health check packages
dotnet add package AspNetCore.HealthChecks.UI.Client --version 6.0.0
dotnet add package AspNetCore.HealthChecks.RabbitMQ --version 6.0.0
dotnet add package AspNetCore.HealthChecks.SqlServer --version 6.0.0
# Add data access
dotnet add package System.Data.SqlClient --version 4.8.5
Health Checks (Critical for Microservices)
Health checks allow orchestrators (Kubernetes, Docker Compose) to know if a service is healthy and route traffic accordingly.
1
2
3
4
5
6
7
// In Program.cs
services
.AddHealthChecks()
.AddSqlServer("Server=localhost;Database=mydb;User Id=sa;Password=P@ssw0rd")
.AddRabbitMQ("amqp://guest:guest@localhost/");
app.MapHealthChecks("/health");
Verification
Test connectivity to dependent services before deploying:
1
2
3
4
5
# Test RabbitMQ connectivity
Test-NetConnection -ComputerName RABBITMQ_HOSTNAME -Port 5672
# Test SQL Server connectivity
Test-NetConnection -ComputerName SQL_SERVER_HOSTNAME -Port 1433
Inter-Service Communication
Synchronous (HTTP/REST): Request-response; simple but creates tight coupling and cascading failures.
1
2
var client = new HttpClient();
var response = await client.GetAsync("https://inventory-service/api/stock/123");
Asynchronous (Message Queue): Fire and forget; decouples services but requires eventual consistency.
1
2
3
4
5
6
7
8
9
10
11
// Publish event
await _messageBus.PublishAsync(new OrderCreatedEvent { OrderId = 123 });
// Subscribe in another service
public class OrderEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task Handle(OrderCreatedEvent @event)
{
// Update inventory based on order
}
}
Best practice: Use async messaging for cross-service events; sync HTTP only for queries with tight SLA.
Service Discovery
In a microservices environment, services move (IP changes, scale up/down). Services must discover each other dynamically.
Kubernetes: Built-in via DNS names (http://payment-service.default.svc.cluster.local)
Docker Compose: Service names are resolved via embedded DNS
Manual: Service registry (Consul, Eureka) or config server
Resilience Patterns
Your service will call other services that will be slow or unavailable. Plan for it.
Timeout
Every external call must have a timeout to prevent hanging requests.
1
2
3
var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(5);
var response = await client.GetAsync("https://external-service/api/data");
Retry with Exponential Backoff
Transient failures should retry, not fail immediately.
1
2
3
4
5
6
7
8
9
10
11
var policy = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt))
);
await policy.ExecuteAsync(async () =>
await client.GetAsync("https://external-service/api/data")
);
Circuit Breaker
If a service is failing consistently, stop calling it temporarily to prevent cascading failures.
1
2
3
4
5
6
var policy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30)
);
Monitoring Microservices
With multiple services, logging and monitoring become critical.
Structured Logging
Log as JSON so you can search and correlate across services.
1
2
3
4
5
builder.Services.AddSerilog(config =>
config.WriteTo.Console(new JsonFormatter())
);
_logger.LogInformation("Order created: {@Order}", order);
Distributed Tracing
Trace a single request as it flows through multiple services using a correlation ID.
1
2
3
4
5
6
7
8
9
10
11
12
// Middleware to add correlation ID to all requests
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers.TryGetValue("X-Correlation-ID", out var id)
? id.ToString()
: Guid.NewGuid().ToString();
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await next.Invoke();
}
});
Key Metrics
Collect these metrics per service:
- Latency: p50, p95, p99 response times
- Throughput: Requests per second
- Error rate: % of requests that fail
- Saturation: CPU, memory, disk, database connections
- Business metrics: Orders/hour, signups/day, revenue/month
The takeaway
Microservices trade operational simplicity for independent scaling and team autonomy. That trade only pays off past a certain size — large teams, divergent scaling needs, deployment cadences that genuinely conflict. Below that line you inherit the network failures, the eventual consistency, and the monitoring burden without the benefits that justify them. So unless you already have the observability to run them, stay a monolith until something specific breaks. When you do split, the resilience patterns above (timeouts, retries, circuit breakers, health checks) aren’t optional extras; they’re the price of admission.