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
┌─────────────────────────────────────────────────────────────────┐
│ 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
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.ymlHow Do Microservices Communicate?
Synchronous: HTTP with HttpClientFactory
// 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
dotnet add package MassTransit
dotnet add package MassTransit.RabbitMQ// 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);
}
}// 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?
dotnet add package Yarp.ReverseProxy// 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/"
}
}
}
}
}
}// 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
# 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
# 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?
// 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?
// 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
// 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.
