Microservices Architecture — A Complete Guide from Basics to Advanced (with real code)
Deep-dive into microservices: what they are, when to choose them, the trade-offs, architecture diagram, real .NET code, communication patterns, observability, and where they fail. Basic to advanced.
- Author
- Randhir Jassal
- Published
- Reading time
- 18 min read
Microservices is an architectural style where a single application is built as a suite of small, independently deployable services that each own one business capability. Each service runs in its own process, owns its own data, and communicates with others over the network.
This guide is the honest picture: when microservices pay off, when they bankrupt teams, the architecture in detail, real .NET code, and the operational reality nobody mentions in the talks.
Why microservices exist
A monolith is one process containing all the code. It works beautifully for the first 5 engineers, the first 10 features, the first year. Then friction sets in:
- A typo in the billing module crashes the marketing pages because they share a process
- The team grows to 30 engineers but only one of them can deploy at a time
- A 6-hour test suite gates every change
- One module needs Python + ML libraries, another needs Go for low-latency, but the monolith is C#
- A traffic spike on one endpoint exhausts the whole server's threads, killing every page
Microservices solve these by decomposing along business boundaries so each team can deploy, scale, and choose tools independently.
The architecture in one diagram
┌─────────────────┐
│ Users │
└────────┬────────┘
│
▼
┌─────────────────┐
│ API Gateway │ Auth, rate-limit, routing
│ (YARP) │
└─┬────┬────┬───┬─┘
┌────────────┘ │ │ └────────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Catalog │ │ Orders │ │ Payments │ │ Inventory │
│ Service │ │ Service │ │ Service │ │ Service │
│ │ │ │ │ │ │ │
│ ASP.NET │ │ ASP.NET │ │ Node.js │ │ Go │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Postgres │ │ Postgres │ │ Mongo │ │ Redis │
│ catalog │ │ orders │ │ payments │ │ stock │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
└────────┬────────┴────────┬────────┴─────────┬───────┘
▼ ▼ ▼
┌──────────────────────────────────────────────┐
│ Event Bus (Kafka / RabbitMQ / SQS) │
│ order.placed, payment.completed, stock.low │
└──────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Notification │
│ Service │
└─────────────────┘
Three things to notice:
- Each service owns its database. Cross-service queries are forbidden. Catalog cannot SELECT from Orders. They talk over HTTP / gRPC or react to events.
- Different languages and stores are allowed. Catalog might be ASP.NET + Postgres; Payments might be Node.js + MongoDB. Pick the right tool per service.
- The Event Bus is the spine. Synchronous calls cause cascading failures; the bus decouples producers from consumers.
Microservices vs Monolith — when each wins
| Factor | Monolith | Microservices |
|---|---|---|
| Team size | 1-15 engineers | 30+ across many teams |
| Deploy speed | One CI pipeline | Each team ships independently |
| Operational complexity | Low — one process | High — many processes, many DBs |
| Failure isolation | None (one bug crashes all) | Strong (one service fails alone) |
| Cross-feature transactions | Easy (single DB transaction) | Hard (sagas, eventual consistency) |
| Tech diversity | One stack | Per-service choice |
| Cost | One server | Many servers + bus + observability + orchestrator |
| Right answer for | A startup launching v1 | A large org with many teams |
If you are below 15 engineers, default to a modular monolith. You get most of the architectural benefit without the operational tax.
A complete microservice in .NET
Catalog Service
catalog-service/
├── Program.cs
├── Catalog.csproj
├── Endpoints/
│ └── ProductEndpoints.cs
├── Models/
│ └── Product.cs
├── Data/
│ └── CatalogDb.cs
├── Events/
│ └── ProductEventPublisher.cs
└── Dockerfile
Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CatalogDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Db")!));
builder.Services.AddSingleton<IProductEventPublisher, KafkaProductEventPublisher>();
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("catalog"))
.WithTracing(t => t.AddAspNetCoreInstrumentation()
.AddNpgsql()
.AddOtlpExporter())
.WithMetrics(m => m.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
builder.Services.AddHealthChecks().AddNpgSql(builder.Configuration.GetConnectionString("Db")!);
var app = builder.Build();
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready");
app.MapProductEndpoints();
app.Run();
ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var products = app.MapGroup("/products").WithTags("Products");
products.MapGet("/", async (CatalogDb db) =>
await db.Products.Select(p => new ProductDto(p.Id, p.Name, p.Price)).ToListAsync());
products.MapGet("/{id:guid}", async (Guid id, CatalogDb db) =>
await db.Products.FindAsync(id) is { } p
? Results.Ok(new ProductDto(p.Id, p.Name, p.Price))
: Results.NotFound());
products.MapPost("/", async (CreateProductRequest req, CatalogDb db, IProductEventPublisher events) =>
{
var product = new Product { Id = Guid.NewGuid(), Name = req.Name, Price = req.Price };
db.Products.Add(product);
// Outbox pattern: persist the event in the same transaction
db.OutboxEvents.Add(new OutboxEvent {
Topic = "catalog.product.created",
Payload = JsonSerializer.Serialize(new { product.Id, product.Name, product.Price }),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", new ProductDto(product.Id, product.Name, product.Price));
});
}
}
Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /out .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Catalog.dll"]
docker-compose.yml (local dev)
services:
catalog:
build: ./catalog-service
environment:
ConnectionStrings__Db: Host=catalog-db;Database=catalog;Username=app;Password=pw
depends_on: { catalog-db: { condition: service_healthy } }
ports: ["5001:8080"]
catalog-db:
image: postgres:16
environment:
POSTGRES_DB: catalog
POSTGRES_USER: app
POSTGRES_PASSWORD: pw
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
kafka:
image: bitnami/kafka:latest
environment:
KAFKA_CFG_NODE_ID: 0
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
Communication patterns
1. Synchronous — HTTP / gRPC
Use when the caller needs the answer to continue.
// Order service calling Catalog service
var catalog = httpFactory.CreateClient("catalog"); // base URL configured in DI
var product = await catalog.GetFromJsonAsync<ProductDto>($"/products/{productId}");
if (product is null) return Results.BadRequest("Unknown product");
Add Polly retries + circuit breaker so a flaky Catalog does not knock out Order:
builder.Services.AddHttpClient("catalog", c => c.BaseAddress = new("http://catalog/"))
.AddStandardResilienceHandler();
2. Asynchronous — event bus
Use when the caller does not need the answer right now, and other services might also care.
// After saving an order, publish an event
await events.PublishAsync(new OrderPlaced(orderId, customerId, total));
// Inventory service consumes
public class InventoryConsumer : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stop)
{
await foreach (var msg in _bus.SubscribeAsync<OrderPlaced>("inventory.order-placed", stop))
{
await _inventory.ReserveAsync(msg.OrderId, msg.Items);
}
}
}
The order service does not know — or care — that inventory exists. It just publishes the fact. Inventory, notifications, analytics, fraud-detection all subscribe independently.
3. The Outbox Pattern — solving dual-write
Naive code has a bug:
// ❌ Two writes that can succeed independently — data drift
await db.SaveChangesAsync(); // succeeds
await bus.PublishAsync(event); // fails — now DB has the order but no event ever fires
The outbox keeps both in one transaction:
db.Orders.Add(order);
db.OutboxEvents.Add(new OutboxEvent {
Topic = "order.placed",
Payload = JsonSerializer.Serialize(orderEvent),
});
await db.SaveChangesAsync(); // both rows commit atomically
// A separate relay polls OutboxEvents and publishes to Kafka, then marks them sent
Service discovery
Hard-coding http://catalog.production.internal:8080 is fragile. Use:
- Kubernetes DNS —
http://catalog.default.svc.cluster.local:8080 - Consul / etcd — services register themselves; clients discover by name
- Cloud load balancers — AWS ALB target groups, Azure Front Door, GCP Cloud Run
In .NET, IHttpClientFactory named clients keep the URL out of business code.
Observability — the three pillars
Without observability, debugging microservices is impossible.
1. Distributed tracing
builder.Services.AddOpenTelemetry().WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddNpgsql()
.AddOtlpExporter(o => o.Endpoint = new Uri("http://otel-collector:4317")));
A single user request flows through 4 services. The trace shows you which one is slow, where the error happened, what query took 800ms.
2. Metrics
.WithMetrics(m => m.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddProcessInstrumentation()
.AddOtlpExporter())
P99 latency, error rate, throughput per service. Dashboards in Grafana. Alerts on degradation.
3. Structured logs
Use Serilog with the trace ID as a property; jump from a metric spike to the trace to the specific log lines.
Resilience patterns
| Pattern | Solves |
|---|---|
| Timeout | A slow downstream cannot pin your thread forever |
| Retry with jitter | Transient network blips do not surface as errors |
| Circuit breaker | A persistently failing dependency does not amplify the outage |
| Bulkhead | One slow endpoint cannot exhaust the thread pool for others |
| Fallback | Return cached or degraded data when the dependency is down |
Polly (or Microsoft.Extensions.Http.Resilience in .NET 8+) provides all of these in one configuration:
builder.Services.AddHttpClient("payments")
.AddStandardResilienceHandler();
Advantages of microservices
- Independent deployment — teams ship without coordinating
- Independent scaling — scale only the hot service
- Tech diversity — pick the right language and store per workload
- Fault isolation — payment service down ≠ entire site down
- Team autonomy — Conway's law works for you, not against
- Smaller codebases per service — easier to onboard
- Polyglot persistence — relational for orders, document for sessions, in-memory for cart
Disadvantages — the hidden tax
- Distributed systems complexity — every cross-service call can fail, time out, return partial data, or arrive out of order
- Operational overhead — Kubernetes, observability stack, CI per service, secrets management, service mesh
- Network latency — what was a function call is now an HTTP round trip; latencies compound
- No cross-service transactions — sagas, compensations, eventual consistency add code and bugs
- Versioning at scale — a breaking contract change ripples through every consumer
- Testing is harder — integration tests need most of the stack running
- Cost — you are running many small servers, many databases, much more infrastructure
- Slower for early-stage products — the time spent building infrastructure is time not spent on features
- On-call burden multiplies — pager for each service unless you have an SRE team
Common anti-patterns
- Distributed monolith — services that must deploy together. You paid the microservices tax with none of the benefits.
- Database-per-feature, not per-service — defeats encapsulation; cross-service queries leak.
- Sync-all-the-way chains — A calls B calls C calls D. Any failure cascades. Break the chain with events.
- Sharing libraries with business logic across services — couples deploys back together.
- No idempotency keys — retries cause duplicate orders, double-charges.
- No outbox — events and DB writes drift; "ghost" orders appear.
Where microservices clearly win
- E-commerce at scale (Amazon, Flipkart, Myntra) — independent teams own catalog, search, recommendations, checkout
- Streaming platforms — encoding, catalog, recommendation, billing all scale and fail independently
- Multi-region SaaS with regulatory boundaries (EU data resident; US data resident)
- Companies past 50 engineers where team independence matters more than infra simplicity
Where microservices fail
- Startups with under 10 engineers — operational tax is fatal
- Teams without SRE / DevOps capacity — Kubernetes alone is a full-time job
- Products that need cross-feature ACID transactions (banking core, ERP) — sagas are usable but painful
- Greenfield projects with unclear domain boundaries — you will draw the lines wrong and pay to redraw them
The pragmatic ladder
- Modular monolith — one process, hard module boundaries, separate DB schemas per module. 90% of products live here happily.
- Modulith with bounded contexts — modules talk through interfaces, not direct calls. Easy to extract a service later.
- Extract the first service — pick the one that scales differently or needs a different language.
- Two services + event bus — introduce the bus before you have ten services; retrofitting is painful.
- Mature microservices — observability, service mesh, contract tests, platform team. 30+ engineers.
You do not start at step 5. Most companies that try lose 12-18 months before retreating.
When you have decided to do it — production checklist
- Each service: its own DB, its own CI pipeline, its own deploy
- API gateway in front (YARP, Kong, Azure Front Door) handling TLS, auth, rate limit
- Event bus with at-least-once delivery + idempotent consumers
- Outbox pattern on every service that publishes events
- Distributed tracing on every service (OpenTelemetry → OTel collector → Jaeger / Tempo / Datadog)
- Centralised structured logs (Loki / Datadog / ELK) with trace IDs
- Centralised metrics with alerts on per-service SLOs
- Contract tests so a producer cannot ship a breaking change without consumer awareness
- Chaos drill at least quarterly: kill a service, observe what happens
Summary
Microservices are a scaling strategy for organizations, not a technology fashion. They solve real coordination problems above ~30 engineers and create new problems below that.
Start with a modular monolith. Make the module boundaries hard. Extract the first service when one of these is true:
- It needs a fundamentally different runtime (Python ML, Go for latency)
- It has its own scaling shape (10× burst, idle most of the day)
- A different team must own it
- Compliance / data residency forces it
Add observability, the outbox pattern, idempotency keys, and contract tests before you have ten services — retrofitting them is a 6-month project.
When the architecture is right and the org has the capacity to operate it, microservices feel like superpowers. When either is missing, they feel like punishment. Pick honestly.
Get the next issue
A short, curated email with the newest posts and questions.