Microservices-Architektur mit .NET: Design und Umsetzung
# Microservices mit .NET
Microservices ermöglichen unabhängige Deployments und Teamautonomie, bringen aber auch deutlich höhere Betriebs- und Architekturkomplexität mit sich. In verteilten Systemen, die ich aufgebaut habe, lag der Unterschied zwischen einer erfolgreichen Microservices-Einführung und einer schmerzhaften immer in der Vorbereitung — zu verstehen, warum man zerlegt, ist wichtiger als die Frage nach dem Wie.
Architektur-Grundlagen
Service-Grenzen
Die wichtigste Entscheidung in einer Microservices-Architektur ist, wo man die Grenzen zieht. Ein schlecht geschnittener Service erzeugt mehr Kopplung als der Monolith, den er ersetzt hat.
In verteilten Systemen, die ich gebaut habe, ist das größte Bedauern der Teams, zu früh aufgespalten zu haben. Beginnen Sie mit einem gut strukturierten Monolithen, identifizieren Sie die Nahtstellen und extrahieren Sie erst, wenn ein klarer betrieblicher oder organisatorischer Grund vorliegt.
Bounded Contexts und Domain-Driven Design
Microservice-Grenzen sollten sich an DDD Bounded Contexts orientieren. Jeder Service kapselt ein kohärentes Domänenmodell:
Wenn zwei Services dasselbe Konzept benötigen (z.B. „Produkt"), pflegt jeder seine eigene Repräsentation. Der Bestellservice braucht vielleicht nur `ProductId`, `Name` und `Price`, während der Inventarservice `ProductId`, `SKU`, `WarehouseLocation` und `StockCount` benötigt.
API-Gateway-Pattern
Ein API Gateway sitzt zwischen Clients und Ihren Services und übernimmt übergreifende Aufgaben:
class=class="code-string">"code-comment">// Program.cs — YARP-basiertes 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();Kommunikationsmuster zwischen Services
Die Wahl des richtigen Kommunikationsmusters ist entscheidend. Es gibt keine einzelne beste Option — jedes hat Vor- und Nachteile, die in unterschiedlichen Kontexten relevant sind.
HTTP/REST
Die einfachste Variante. Funktioniert gut für synchrone Request-Response-Abläufe, bei denen eine sofortige Antwort benötigt wird.
Vorteile: Universelle Werkzeugunterstützung, einfaches Debugging, menschenlesbare Payloads
Nachteile: Enge Kopplung zwischen Aufrufer und Aufgerufenem, kaskadierende Fehler, höhere Latenz
class=class="code-string">"code-comment">// Typisierter HTTP-Client mit Resilienz-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
Binärprotokoll auf Basis von HTTP/2. Ideal für interne Service-zu-Service-Aufrufe, bei denen Performance zählt.
Vorteile: Starke Verträge über .proto-Dateien, Streaming-Unterstützung, ~10x schnellere Serialisierung als JSON
Nachteile: Ohne gRPC-Web nicht browserfreundlich, schwieriger zu debuggen, erfordert Proto-Management
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;
}class=class="code-string">"code-comment">// gRPC-Server-Implementierung
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
};
}
}Asynchrones Messaging mit RabbitMQ
Für Workflows, die keine sofortige Antwort erfordern, entkoppeln Nachrichtenwarteschlangen Services sowohl zeitlich als auch in der Verfügbarkeit. Dieses Muster setze ich in Produktivsystemen am häufigsten ein.
class=class="code-string">"code-comment">// RabbitMQ-Consumer mit 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">"Inventarreservierung für Bestellung {OrderId} wird verarbeitet",
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">"Unzureichender Bestand für Bestellung {OrderId}", order.OrderId);
await context.Publish(new InventoryReservationFailedEvent
{
OrderId = order.OrderId,
Reason = ex.Message
});
}
}
}
class=class="code-string">"code-comment">// MassTransit-Registrierung
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)));
});
});
});Muster-Vergleich im Überblick
| Kriterium | HTTP/REST | gRPC | Async Messaging |
|---|---|---|---|
| Kopplung | Hoch | Hoch | Niedrig |
| Latenz | Mittel | Niedrig | Variabel |
| Zuverlässigkeit | Erfordert Retries | Erfordert Retries | Eingebaute Dauerhaftigkeit |
| Debugging | Einfach | Mittel | Schwieriger |
| Ideal für | Externe APIs | Interne High-Perf | Eventgesteuerte Workflows |
Migration vom Monolith zu Microservices
Jedes erfolgreiche Microservices-Projekt, an dem ich gearbeitet habe, begann als Monolith-Migration, nicht als Greenfield-Projekt. Hier ist eine praxiserprobte Strategie.
Phase 1: Den Monolith vorbereiten
Bevor Sie irgendetwas herauslösen, strukturieren Sie intern um. Führen Sie Modulgrenzen innerhalb des Monolithen ein, die Ihre künftigen Service-Grenzen widerspiegeln.
Phase 2: Strangler-Fig-Extraktion
Extrahieren Sie einen Service nach dem anderen, beginnend mit der am wenigsten gekoppelten Domäne. Leiten Sie Traffic durch eine Fassade, die entscheidet, ob der Monolith oder der neue Service aufgerufen wird.
Phase 3: Datenmigration
Das ist der schwierigste Teil. Jeder Service muss seinen eigenen Datenspeicher besitzen.
Phase 4: Umstellung abschließen
Containerisierung und Orchestrierung
Docker-Konfiguration
Ein gut strukturiertes Dockerfile nutzt Multi-Stage-Builds, um Produktions-Images minimal zu halten:
# Build-Phase
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
# Laufzeit-Phase
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 für lokale Entwicklung
# 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 und Resilienz
Jeder Microservice muss Gesundheitsinformationen bereitstellen. Ohne diese können Orchestratoren keine intelligenten Routing- oder Neustart-Entscheidungen treffen.
class=class="code-string">"code-comment">// Program.cs — Health-Check-Konfiguration
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: Lebt der Prozess?
app.MapHealthChecks(class="code-string">"/health/live", new HealthCheckOptions
{
Predicate = _ => false class=class="code-string">"code-comment">// Keine Checks, bestätigt nur, dass die App antwortet
});
class=class="code-string">"code-comment">// Readiness: Kann dieser Service Traffic verarbeiten?
app.MapHealthChecks(class="code-string">"/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains(class="code-string">"ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});class=class="code-string">"code-comment">// Benutzerdefinierter Health Check
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} Bestellungen seit >class="code-number">30 Min in Verarbeitung blockiert");
if (stuckOrders > class="code-number">10)
return HealthCheckResult.Degraded(
$class="code-string">"{stuckOrders} Bestellungen in Verarbeitung blockiert");
return HealthCheckResult.Healthy();
}
}Verteiltes Tracing und Observability
Im Monolith verrät ein Stack-Trace alles. Bei Microservices kann eine einzelne Benutzeranfrage fünf Services durchlaufen — und ohne verteiltes Tracing ist Debugging nahezu unmöglich.
OpenTelemetry-Integration
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");
}));Strukturiertes Logging mit Korrelation
class=class="code-string">"code-comment">// Serilog-Konfiguration mit Trace-Korrelation
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"));Was gemessen werden sollte
Die drei Säulen der Observability dienen jeweils einem anderen Zweck:
In verteilten Systemen, die ich gebaut habe, ist das wertvollste Dashboard jenes, das die p99-Latenz pro Service-Endpunkt neben der Fehlerrate zeigt. Wenn diese beiden Linien auseinanderlaufen, bildet sich ein Problem, bevor es zum Ausfall wird.
Häufige Microservice-Fehler
Nach der Arbeit an mehreren Microservices-Migrationen sind dies die Muster, in die Teams immer wieder verfallen.
1. Verteilter Monolith
Sie teilen die Codebasis in Services auf, aber jede Anfrage erfordert weiterhin synchrone Aufrufe durch fünf davon in Reihe. Sie haben die gesamte Komplexität von Microservices ohne die Unabhängigkeit. Wenn Sie Service A nicht deployen können, ohne auch Service B zu deployen, sind es keine unabhängigen Services.
2. Vorzeitige Zerlegung
Aufteilen, bevor Sie die Domänengrenzen verstanden haben, führt zu falschen Service-Schnitten. Es ist weitaus schwieriger, zwei Services zusammenzuführen, als einen zu teilen. Beginnen Sie mit einem modularen Monolithen und extrahieren Sie erst, wenn Sie starke Belege für die Grenze haben.
3. Gemeinsame Datenbank
Zwei Services, die aus derselben Datenbanktabelle lesen, machen den gesamten Zweck zunichte. Schemaänderungen werden zu koordinierten Deployments. Jeder Service muss seine Daten besitzen, selbst wenn das Duplizierung bedeutet.
4. Datenkonsistenz ignorieren
Verteilte Transaktionen (Two-Phase-Commit) über Services hinweg funktionieren nicht im großen Maßstab. Akzeptieren Sie Eventual Consistency, implementieren Sie das Saga-Pattern für mehrstufige Workflows und entwerfen Sie kompensierende Aktionen für Fehlerfälle.
5. Keine Vertragstests
Integrationstests, die alle Services hochfahren, sind langsam, instabil und laufen in CI nicht zuverlässig. Nutzen Sie Consumer-Driven Contract Tests (z.B. Pact), um zu überprüfen, dass Services sich auf API-Formen einigen, ohne dass alle Services laufen müssen.
6. Unzureichende operative Investition
Microservices erfordern ernsthafte Infrastruktur: CI/CD pro Service, zentrales Logging, verteiltes Tracing, Health Checks, Alerting, Secrets-Management. Wenn Sie nicht die Plattformreife dafür haben, werden Microservices Sie ausbremsen.
7. Zu viele Services zu schnell
Ich habe Teams gesehen, die im ersten Quartal 30 Services erstellt haben. Jeder einzelne braucht seine eigene CI-Pipeline, Monitoring, Bereitschaftsrotation und Dokumentation. Beginnen Sie mit 2-3 Extraktionen, bauen Sie die Plattformfähigkeiten auf, dann beschleunigen Sie.
Wann Microservices sinnvoll sind
Nicht jedes System profitiert von Microservices. Sie sind gerechtfertigt, wenn:
Sie sind nicht gerechtfertigt, wenn:
Fazit
Microservices sind kein Standard-Upgrade, sondern eine strategische Entscheidung. Die Technologie — .NET, Docker, RabbitMQ, gRPC — ist der einfachere Teil. Das Schwierige ist, die Grenzen richtig zu ziehen, in Observability zu investieren und die betriebliche Disziplin aufzubauen, um Dutzende unabhängiger Services in Produktion zu betreiben. Wählen Sie Microservices, wenn der geschäftliche Kontext es wirklich erfordert, und investieren Sie in die Plattform, bevor Sie in die Zerlegung investieren.
Ich unterstütze gern bei Readiness-Assessment und Migrationsplanung für Ihr System.
Verwandte Artikel
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Clean Architecture in .NET: Skalierbare Projektstruktur
Wenden Sie Clean Architecture in .NET-Projekten an. Schichten, Abhängigkeiten und testbarer Code.
.NET CI/CD: Automatisiertes Deployment mit GitHub Actions und Azure DevOps
Entwerfen Sie CI/CD-Pipelines für .NET-Projekte. GitHub Actions und Azure DevOps.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen