.NET Core

Microservices Architecture with .NET: Design and Implementation

16 min readFebruary 9, 2026Updated Mar 9, 2026
.NET microservicesMicroservices C#Docker .NETKubernetes .NETgRPC .NETRabbitMQ .NETService communicationDistributed systems .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.

  • Model services around business capabilities, not technical layers
  • Avoid shared databases across services — each service owns its data
  • Keep contracts explicit and versioned
  • Apply the Strangler Fig pattern when extracting from a monolith
  • One team should own one or more services, never the reverse

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:

  • Order Service — owns order lifecycle, validation rules, pricing
  • Inventory Service — owns stock levels, reservations, warehouse mapping
  • Payment Service — owns payment processing, refund logic, ledger entries

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:

csharp
// Program.cs — YARP-based API Gateway
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("default", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(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

csharp
// Typed HTTP client with resilience policies
builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>(client =>
{
    client.BaseAddress = new Uri("https://order-service:5001");
    client.Timeout = TimeSpan.FromSeconds(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

csharp
// inventory.proto
syntax = "proto3";

option csharp_namespace = "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 = 1;
  string warehouse_id = 2;
}

message StockResponse {
  string product_id = 1;
  int32 available_quantity = 2;
  bool is_available = 3;
}

message ReserveRequest {
  string order_id = 1;
  repeated ReserveItem items = 2;
}

message ReserveItem {
  string product_id = 1;
  int32 quantity = 2;
}

message ReserveResponse {
  bool success = 1;
  string reservation_id = 2;
  string failure_reason = 3;
}

message StockSubscription {
  repeated string product_ids = 1;
}

message StockUpdate {
  string product_id = 1;
  int32 new_quantity = 2;
  string timestamp = 3;
}
csharp
// 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 ?? 0,
            IsAvailable = (stock?.Quantity ?? 0) > 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.

csharp
// 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(
            "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,
                "Insufficient stock for Order {OrderId}", order.OrderId);

            await context.Publish(new InventoryReservationFailedEvent
            {
                OrderId = order.OrderId,
                Reason = ex.Message
            });
        }
    }
}

// MassTransit registration
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderCreatedConsumer>();

    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host("rabbitmq", "/", h =>
        {
            h.Username("guest");
            h.Password("guest");
        });

        cfg.ReceiveEndpoint("inventory-order-created", e =>
        {
            e.ConfigureConsumer<OrderCreatedConsumer>(ctx);
            e.UseMessageRetry(r => r.Intervals(
                TimeSpan.FromSeconds(1),
                TimeSpan.FromSeconds(5),
                TimeSpan.FromSeconds(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.

  • Identify bounded contexts through domain analysis
  • Separate shared data access into per-module repositories
  • Introduce internal interfaces between modules
  • Add integration tests at module 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.

  • Start with a read-only service (lower risk)
  • Run both paths in parallel and compare results
  • Gradually shift write operations
  • Keep the monolith's code for the migrated module as a fallback

Phase 3: Data Migration

This is the hardest part. Each service must own its data store.

  • Use Change Data Capture (CDC) to sync during transition
  • Accept eventual consistency between service boundaries
  • Implement compensating transactions for cross-service operations
  • Never share a database between two services in the final state

Phase 4: Cut Over

  • Remove the monolith code for the migrated module
  • Decommission shared database tables
  • Update monitoring and alerting for the new topology
  • Document the new service contract and ownership

Containerization and Orchestration

Docker Configuration

A well-structured Dockerfile uses multi-stage builds to keep production images minimal:

dockerfile
# 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

yaml
# 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.

csharp
// Program.cs — Health check configuration
builder.Services.AddHealthChecks()
    .AddNpgSql(
        builder.Configuration.GetConnectionString("OrderDb")!,
        name: "postgresql",
        tags: new[] { "db", "ready" })
    .AddRabbitMQ(
        new Uri("amqp://guest:guest@rabbitmq:5672"),
        name: "rabbitmq",
        tags: new[] { "messaging", "ready" })
    .AddCheck<OrderProcessingHealthCheck>(
        "order-processing",
        tags: new[] { "custom", "ready" });

var app = builder.Build();

// Liveness: is the process alive?
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // No checks, just confirms the app responds
});

// Readiness: can this service handle traffic?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
csharp
// 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(30), cancellationToken);

        if (stuckOrders > 50)
            return HealthCheckResult.Unhealthy(
                $"{stuckOrders} orders stuck in processing for >30 min");

        if (stuckOrders > 10)
            return HealthCheckResult.Degraded(
                $"{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

csharp
// Program.cs — OpenTelemetry setup
builder.Services.AddOpenTelemetry()
    .ConfigureResource(res => res
        .AddService(
            serviceName: "OrderService",
            serviceVersion: "1.0.0"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddGrpcClientInstrumentation()
        .AddSource("MassTransit")
        .AddOtlpExporter(opt =>
        {
            opt.Endpoint = new Uri("http://otel-collector:4317");
        }))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddMeter("OrderService.Metrics")
        .AddOtlpExporter(opt =>
        {
            opt.Endpoint = new Uri("http://otel-collector:4317");
        }));

Structured Logging with Correlation

csharp
// Serilog configuration with trace correlation
builder.Host.UseSerilog((context, config) => config
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .Enrich.WithProperty("ServiceName", "OrderService")
    .Enrich.WithSpanId()
    .Enrich.WithTraceId()
    .WriteTo.Console(new JsonFormatter())
    .WriteTo.Seq("http://seq:5341"));

What to Measure

The three pillars of observability each serve a different purpose:

  • Logs — discrete events. Use structured logging with correlation IDs so you can trace a request across services.
  • Metrics — aggregated measurements. Track request rates, error rates, latency percentiles (p50, p95, p99), queue depths, and saturation.
  • Traces — the full journey of a request. Every inter-service call should propagate trace context so you can reconstruct the call chain.

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:

  • Multiple teams need independent release cadence
  • Different domains have different scaling profiles
  • Monolith evolution cost has become unacceptable
  • You have the platform maturity to operate distributed systems
  • Regulatory or compliance requirements demand isolation

They are not justified when:

  • You have a small team (under 5-8 developers)
  • The domain is not well understood yet
  • You want to use microservices because they sound modern
  • You don't have CI/CD, monitoring, or container orchestration in place

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

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch