.NET 8 Microservices: Architecture Guide
Microservices are great. Microservices are also oversold.
The right answer isn't "always microservices" or "never microservices." It's understanding when they help and how to implement them well. With .NET 8, Microsoft has made microservice development genuinely pleasant. Let's talk about how to do it right.
When Microservices Make Sense
You probably need microservices if:
Organisational: Multiple teams working on the same system who need to deploy independently
Scale: Different parts of your system have dramatically different load profiles
Technology: Different components genuinely benefit from different tech stacks
Risk isolation: Failure in one part shouldn't take down everything
You probably don't need microservices if:
Small team: <10 developers working on the system Simple domain: Straightforward CRUD without complex business logic Uniform scale: Everything scales together Startup phase: You're still finding product-market fit
The monolith is not a bad word. A well-structured modular monolith beats a poorly-designed microservice architecture every time.
.NET 8 Microservice Stack
Core Components
A typical .NET 8 microservice:
┌─────────────────────────────────────────────────────┐
│ API Layer (ASP.NET Core Minimal APIs) │
├─────────────────────────────────────────────────────┤
│ Application Layer (MediatR handlers) │
├─────────────────────────────────────────────────────┤
│ Domain Layer (Business logic) │
├─────────────────────────────────────────────────────┤
│ Infrastructure (EF Core, external services) │
└─────────────────────────────────────────────────────┘
Minimal APIs
.NET 8's minimal APIs are perfect for microservices:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
var app = builder.Build();
app.MapGet("/orders/{id}", async (Guid id, IMediator mediator) =>
await mediator.Send(new GetOrderQuery(id)));
app.MapPost("/orders", async (CreateOrderCommand command, IMediator mediator) =>
Results.Created(quot;/orders/{await mediator.Send(command)}", null));
app.Run();
Less ceremony than full MVC. Faster startup. Better for containers.
MediatR for Command/Query
Separate reads from writes:
// Query
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto>;
public class GetOrderQueryHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
private readonly IOrderRepository _repository;
public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
{
var order = await _repository.GetByIdAsync(request.OrderId, ct);
return order.ToDto();
}
}
// Command
public record CreateOrderCommand(Guid CustomerId, List<OrderItem> Items)
: IRequest<Guid>;
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
private readonly IEventBus _eventBus;
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = Order.Create(request.CustomerId, request.Items);
await _repository.AddAsync(order, ct);
await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id), ct);
return order.Id;
}
}
Entity Framework Core
For data access in microservices:
public class OrderDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(OrderDbContext).Assembly);
}
}
Each microservice owns its data. Don't share databases between services.
Communication Patterns
Synchronous (HTTP)
For request/response when you need immediate answers:
// Typed client with Polly resilience
builder.Services.AddHttpClient<IInventoryClient, InventoryClient>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
Use sparingly. Synchronous calls create coupling.
Asynchronous (Messaging)
For events and commands that don't need immediate response:
// With MassTransit and Azure Service Bus
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();
x.UsingAzureServiceBus((context, cfg) =>
{
cfg.Host(configuration["ServiceBus:ConnectionString"]);
cfg.ConfigureEndpoints(context);
});
});
// Publisher
public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;
public async Task CreateOrder(Order order)
{
// ... create order
await _publishEndpoint.Publish(new OrderCreatedEvent(order.Id));
}
}
// Consumer
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
// Handle the event
}
}
Async messaging is the backbone of microservice communication.
Data Patterns
Database per Service
Each service owns its data:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Order Service│ │ Inventory │ │ Customer │
│ │ │ Service │ │ Service │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Orders │ │ Inventory │ │ Customers │
│ Database │ │ Database │ │ Database │
└──────────────┘ └──────────────┘ └──────────────┘
No sharing. If a service needs another service's data, it requests it via API or event.
Event Sourcing (Sometimes)
For complex domains where audit and history matter:
public class Order : AggregateRoot
{
private readonly List<OrderItem> _items = new();
public void AddItem(Product product, int quantity)
{
var evt = new OrderItemAddedEvent(Id, product.Id, quantity);
Apply(evt);
AddDomainEvent(evt);
}
private void Apply(OrderItemAddedEvent evt)
{
_items.Add(new OrderItem(evt.ProductId, evt.Quantity));
}
}
Event sourcing adds complexity. Use when you genuinely need the history.
Saga Pattern for Distributed Transactions
When operations span services:
public class CreateOrderSaga : MassTransitStateMachine<CreateOrderSagaState>
{
public CreateOrderSaga()
{
InstanceState(x => x.CurrentState);
Event(() => OrderSubmitted, x => x.CorrelateById(m => m.Message.OrderId));
Event(() => PaymentCompleted, x => x.CorrelateById(m => m.Message.OrderId));
Event(() => InventoryReserved, x => x.CorrelateById(m => m.Message.OrderId));
Initially(
When(OrderSubmitted)
.Then(context => context.Saga.OrderId = context.Message.OrderId)
.Publish(context => new ReserveInventoryCommand(context.Saga.OrderId))
.TransitionTo(ReservingInventory));
During(ReservingInventory,
When(InventoryReserved)
.Publish(context => new ProcessPaymentCommand(context.Saga.OrderId))
.TransitionTo(ProcessingPayment),
When(InventoryReservationFailed)
.Publish(context => new CancelOrderCommand(context.Saga.OrderId))
.TransitionTo(Cancelled));
// ... more states
}
}
Deployment Patterns
Container-First
Design for containers from the start:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["OrderService.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release -o /app/build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OrderService.dll"]
Health Checks
Essential for orchestration:
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<MessageBusHealthCheck>("messagebus");
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
Configuration
Use Azure App Configuration or similar:
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(connectionString)
.Select(KeyFilter.Any, LabelFilter.Null)
.Select(KeyFilter.Any, builder.Environment.EnvironmentName);
});
Observability
Structured Logging with Serilog
builder.Host.UseSerilog((context, config) =>
{
config.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithProperty("Service", "OrderService");
});
Distributed Tracing
OpenTelemetry for tracing across services:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("MassTransit")
.AddOtlpExporter();
});
Tracing is essential for debugging distributed systems.
Common Mistakes
Too many services too soon: Start with fewer, larger services. Split when needed.
Synchronous everything: Creates distributed monolith. Embrace async.
Shared databases: Defeats the purpose. Own your data.
No observability: Can't debug what you can't see. Add it early.
Ignoring network failures: The network is not reliable. Handle it.
Our Experience
We've built .NET microservice systems for production workloads. We've also helped clients realise they didn't need microservices—a modular monolith was fine.
The architecture should fit the problem. We help figure out what's right for your situation.
Talk to us about your .NET architecture needs.