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

  • 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
    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

    csharp
    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

    csharp
    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;
    }
    csharp
    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.

    csharp
    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.

  • 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
    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
    });
    csharp
    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

    csharp
    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

    csharp
    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:

  • **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