Microservices with .NET: Building Distributed Systems
Backend19 min read

Microservices with .NET: Building Distributed Systems

Design and implement microservices in .NET. Service communication, message queues, API Gateway, Docker, and resilience patterns.

Taha Kocal

Taha Kocal

Full Stack Developer

Feb 20, 2025
#Microservices#.NET#Docker#RabbitMQ#Architecture

Microservices architecture is a design approach that breaks an application into small, independently deployable services, each owning its own data store and communicating over the network instead of in-process calls. In the .NET ecosystem this typically means separate ASP.NET Core services that talk synchronously over HTTP or gRPC and asynchronously through a message broker such as RabbitMQ, sit behind an API Gateway like YARP, and run in Docker containers orchestrated with Docker Compose or Kubernetes. Compared with a monolith, microservices trade extra operational complexity for independent scaling, isolated failures, technology flexibility, and team autonomy - each service can be deployed on its own release cadence. In this guide we build a working microservices system in .NET step by step: service structure, synchronous and event-driven communication, an API Gateway, Docker Compose setup, the Saga pattern for distributed transactions, health checks, and distributed caching with Redis.

Architecture Overview

text
┌─────────────────────────────────────────────────────────────────┐
│                          API Gateway                             │
│                         (Ocelot/YARP)                            │
└─────────────────────────────┬───────────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
        ▼                     ▼                     ▼
┌───────────────┐     ┌───────────────┐     ┌───────────────┐
│  User Service │     │ Order Service │     │Product Service│
│   (REST API)  │     │   (REST API)  │     │   (REST API)  │
└───────┬───────┘     └───────┬───────┘     └───────┬───────┘
        │                     │                     │
        ▼                     ▼                     ▼
┌───────────────┐     ┌───────────────┐     ┌───────────────┐
│   SQL Server  │     │  PostgreSQL   │     │    MongoDB    │
└───────────────┘     └───────────────┘     └───────────────┘
        │                     │                     │
        └─────────────────────┼─────────────────────┘
                              │
                    ┌─────────▼─────────┐
                    │     RabbitMQ      │
                    │  (Message Broker) │
                    └───────────────────┘

Project Structure

text
src/
├── ApiGateway/
│   └── Gateway.API/
├── Services/
│   ├── User/
│   │   ├── User.API/
│   │   ├── User.Application/
│   │   ├── User.Domain/
│   │   └── User.Infrastructure/
│   ├── Order/
│   │   ├── Order.API/
│   │   ├── Order.Application/
│   │   ├── Order.Domain/
│   │   └── Order.Infrastructure/
│   └── Product/
│       └── ...
├── BuildingBlocks/
│   ├── EventBus/
│   ├── Common/
│   └── Contracts/
└── docker-compose.yml

How Do Microservices Communicate?

Synchronous: HTTP with HttpClientFactory

csharp
// Program.cs - Configure typed HttpClient
builder.Services.AddHttpClient<IProductService, ProductService>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Services:Product:Url"]!);
    client.Timeout = TimeSpan.FromSeconds(30);
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

// Resilience policies with Polly
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .WaitAndRetryAsync(3, retryAttempt =>
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

// ProductService client
public class ProductService : IProductService
{
    private readonly HttpClient _httpClient;

    public ProductService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<ProductDto?> GetProductAsync(Guid productId, CancellationToken cancellationToken)
    {
        var response = await _httpClient.GetAsync($"/api/products/{productId}", cancellationToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<ProductDto>(cancellationToken: cancellationToken);
    }
}

Asynchronous: Message Queue with RabbitMQ

bash
dotnet add package MassTransit
dotnet add package MassTransit.RabbitMQ
csharp
// BuildingBlocks/Contracts/Events/OrderCreatedEvent.cs
public record OrderCreatedEvent
{
    public Guid OrderId { get; init; }
    public Guid UserId { get; init; }
    public List<OrderItemEvent> Items { get; init; } = new();
    public decimal TotalAmount { get; init; }
    public DateTime CreatedAt { get; init; }
}

public record OrderItemEvent
{
    public Guid ProductId { get; init; }
    public int Quantity { get; init; }
    public decimal Price { get; init; }
}

// Order.API/Program.cs - Publisher configuration
builder.Services.AddMassTransit(x =>
{
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(builder.Configuration["RabbitMQ:Host"], "/", h =>
        {
            h.Username(builder.Configuration["RabbitMQ:Username"]!);
            h.Password(builder.Configuration["RabbitMQ:Password"]!);
        });

        cfg.ConfigureEndpoints(context);
    });
});

// Publishing event in Order Service
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    private readonly IPublishEndpoint _publishEndpoint;

    public async Task<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        // Create order logic...

        // Publish event
        await _publishEndpoint.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            UserId = order.UserId,
            Items = order.Items.Select(i => new OrderItemEvent
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                Price = i.Price
            }).ToList(),
            TotalAmount = order.TotalAmount,
            CreatedAt = DateTime.UtcNow
        }, cancellationToken);

        return Result.Success(order.Id);
    }
}
csharp
// Product.API/Consumers/OrderCreatedConsumer.cs
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OrderCreatedConsumer> _logger;

    public OrderCreatedConsumer(
        IProductRepository productRepository,
        IUnitOfWork unitOfWork,
        ILogger<OrderCreatedConsumer> logger)
    {
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
    {
        var message = context.Message;

        _logger.LogInformation("Processing OrderCreatedEvent for order {OrderId}", message.OrderId);

        // Update stock for each item
        foreach (var item in message.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);
            if (product != null)
            {
                product.DecreaseStock(item.Quantity);
            }
        }

        await _unitOfWork.SaveChangesAsync();

        _logger.LogInformation("Stock updated for order {OrderId}", message.OrderId);
    }
}

// Product.API/Program.cs - Consumer configuration
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderCreatedConsumer>();

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(builder.Configuration["RabbitMQ:Host"], "/", h =>
        {
            h.Username(builder.Configuration["RabbitMQ:Username"]!);
            h.Password(builder.Configuration["RabbitMQ:Password"]!);
        });

        cfg.ReceiveEndpoint("product-order-created", e =>
        {
            e.ConfigureConsumer<OrderCreatedConsumer>(context);
            e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(5)));
        });

        cfg.ConfigureEndpoints(context);
    });
});

How Do You Build an API Gateway with YARP?

bash
dotnet add package Yarp.ReverseProxy
json
// Gateway.API/appsettings.json
{
  "ReverseProxy": {
    "Routes": {
      "users-route": {
        "ClusterId": "users-cluster",
        "Match": {
          "Path": "/api/users/{**catch-all}"
        },
        "Transforms": [
          { "PathRemovePrefix": "/api/users" },
          { "PathPrefix": "/api/users" }
        ]
      },
      "orders-route": {
        "ClusterId": "orders-cluster",
        "Match": {
          "Path": "/api/orders/{**catch-all}"
        }
      },
      "products-route": {
        "ClusterId": "products-cluster",
        "Match": {
          "Path": "/api/products/{**catch-all}"
        }
      }
    },
    "Clusters": {
      "users-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://user-service:80/"
          }
        },
        "LoadBalancingPolicy": "RoundRobin"
      },
      "orders-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://order-service:80/"
          }
        }
      },
      "products-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://product-service:80/"
          }
        }
      }
    }
  }
}
csharp
// Gateway.API/Program.cs
var builder = WebApplication.CreateBuilder(args);

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

// Add authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // JWT configuration
    });

// Add rate limiting
builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", config =>
    {
        config.Window = TimeSpan.FromMinutes(1);
        config.PermitLimit = 100;
    });
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapReverseProxy();

app.Run();

Docker Compose Setup

yaml
# docker-compose.yml
version: '3.8'

services:
  gateway:
    build:
      context: .
      dockerfile: src/ApiGateway/Gateway.API/Dockerfile
    ports:
      - "5000:80"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
    depends_on:
      - user-service
      - order-service
      - product-service

  user-service:
    build:
      context: .
      dockerfile: src/Services/User/User.API/Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=UsersDb;User=sa;Password=Password123!;TrustServerCertificate=True
      - RabbitMQ__Host=rabbitmq
    depends_on:
      - sqlserver
      - rabbitmq

  order-service:
    build:
      context: .
      dockerfile: src/Services/Order/Order.API/Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Host=postgres;Database=OrdersDb;Username=postgres;Password=Password123!
      - RabbitMQ__Host=rabbitmq
      - Services__Product__Url=http://product-service
    depends_on:
      - postgres
      - rabbitmq

  product-service:
    build:
      context: .
      dockerfile: src/Services/Product/Product.API/Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - MongoDB__ConnectionString=mongodb://mongodb:27017
      - MongoDB__DatabaseName=ProductsDb
      - RabbitMQ__Host=rabbitmq
    depends_on:
      - mongodb
      - rabbitmq

  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=Password123!
    ports:
      - "1433:1433"
    volumes:
      - sqlserver_data:/var/opt/mssql

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=Password123!
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongodb:
    image: mongo:7
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db

  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"
      - "15672:15672"
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq

volumes:
  sqlserver_data:
  postgres_data:
  mongodb_data:
  rabbitmq_data:

Service Dockerfile

dockerfile
# src/Services/Order/Order.API/Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy solution and project files
COPY ["src/Services/Order/Order.API/Order.API.csproj", "Services/Order/Order.API/"]
COPY ["src/Services/Order/Order.Application/Order.Application.csproj", "Services/Order/Order.Application/"]
COPY ["src/Services/Order/Order.Domain/Order.Domain.csproj", "Services/Order/Order.Domain/"]
COPY ["src/Services/Order/Order.Infrastructure/Order.Infrastructure.csproj", "Services/Order/Order.Infrastructure/"]
COPY ["src/BuildingBlocks/Contracts/Contracts.csproj", "BuildingBlocks/Contracts/"]

# Restore
RUN dotnet restore "Services/Order/Order.API/Order.API.csproj"

# Copy source
COPY src/ .

# Build
WORKDIR "/src/Services/Order/Order.API"
RUN dotnet build -c Release -o /app/build

# Publish
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false

# Final
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Order.API.dll"]

How Does the Saga Pattern Handle Distributed Transactions?

csharp
// Order Saga - Orchestration pattern
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public State Submitted { get; private set; }
    public State StockReserved { get; private set; }
    public State PaymentProcessed { get; private set; }
    public State Completed { get; private set; }
    public State Failed { get; private set; }

    public Event<OrderSubmittedEvent> OrderSubmitted { get; private set; }
    public Event<StockReservedEvent> StockReserved { get; private set; }
    public Event<StockReservationFailedEvent> StockReservationFailed { get; private set; }
    public Event<PaymentProcessedEvent> PaymentProcessed { get; private set; }
    public Event<PaymentFailedEvent> PaymentFailed { get; private set; }

    public OrderSaga()
    {
        InstanceState(x => x.CurrentState);

        Event(() => OrderSubmitted, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => StockReserved, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => StockReservationFailed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentProcessed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PaymentFailed, x => x.CorrelateById(m => m.Message.OrderId));

        Initially(
            When(OrderSubmitted)
                .Then(context =>
                {
                    context.Saga.OrderId = context.Message.OrderId;
                    context.Saga.UserId = context.Message.UserId;
                    context.Saga.TotalAmount = context.Message.TotalAmount;
                })
                .Publish(context => new ReserveStockCommand
                {
                    OrderId = context.Message.OrderId,
                    Items = context.Message.Items
                })
                .TransitionTo(Submitted)
        );

        During(Submitted,
            When(StockReserved)
                .Publish(context => new ProcessPaymentCommand
                {
                    OrderId = context.Saga.OrderId,
                    UserId = context.Saga.UserId,
                    Amount = context.Saga.TotalAmount
                })
                .TransitionTo(StockReserved),

            When(StockReservationFailed)
                .Publish(context => new OrderFailedEvent
                {
                    OrderId = context.Saga.OrderId,
                    Reason = "Insufficient stock"
                })
                .TransitionTo(Failed)
        );

        During(StockReserved,
            When(PaymentProcessed)
                .Publish(context => new OrderCompletedEvent
                {
                    OrderId = context.Saga.OrderId
                })
                .TransitionTo(Completed)
                .Finalize(),

            When(PaymentFailed)
                .Publish(context => new ReleaseStockCommand
                {
                    OrderId = context.Saga.OrderId
                })
                .Publish(context => new OrderFailedEvent
                {
                    OrderId = context.Saga.OrderId,
                    Reason = "Payment failed"
                })
                .TransitionTo(Failed)
        );
    }
}

public class OrderSagaState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; } = string.Empty;
    public Guid OrderId { get; set; }
    public Guid UserId { get; set; }
    public decimal TotalAmount { get; set; }
}

How Do You Add Health Checks Across Services?

csharp
// Each service health check
builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString, name: "database")
    .AddRabbitMQ(rabbitConnectionString, name: "rabbitmq")
    .AddUrlGroup(new Uri("http://dependent-service/health"), name: "dependent-service");

// Gateway aggregated health check
builder.Services.AddHealthChecks()
    .AddUrlGroup(new Uri("http://user-service/health"), name: "user-service")
    .AddUrlGroup(new Uri("http://order-service/health"), name: "order-service")
    .AddUrlGroup(new Uri("http://product-service/health"), name: "product-service");

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = async (context, report) =>
    {
        context.Response.ContentType = "application/json";
        var result = JsonSerializer.Serialize(new
        {
            status = report.Status.ToString(),
            services = report.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                duration = e.Value.Duration.TotalMilliseconds
            })
        });
        await context.Response.WriteAsync(result);
    }
});

Distributed Caching with Redis

csharp
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration["Redis:ConnectionString"];
    options.InstanceName = "MyApp_";
});

// Caching service
public class CacheService : ICacheService
{
    private readonly IDistributedCache _cache;
    private readonly JsonSerializerOptions _jsonOptions;

    public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
    {
        var data = await _cache.GetStringAsync(key, cancellationToken);
        return data is null ? default : JsonSerializer.Deserialize<T>(data, _jsonOptions);
    }

    public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default)
    {
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = expiration ?? TimeSpan.FromMinutes(5)
        };

        var data = JsonSerializer.Serialize(value, _jsonOptions);
        await _cache.SetStringAsync(key, data, options, cancellationToken);
    }

    public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
    {
        await _cache.RemoveAsync(key, cancellationToken);
    }
}

Microservices aren't a silver bullet. They add complexity - network latency, distributed transactions, and operational overhead. Start with a modular monolith and extract services when you have a clear need. The best architecture is the simplest one that solves your problems.

This foundation gives you scalable, resilient microservices. Remember: eventual consistency is the norm, design for failure, and invest in observability. Your monitoring and logging strategy is as important as the code itself.

Share this article

Related Articles