.NET Core

Microservices-Architektur mit .NET: Design und Umsetzung

16 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
.NET microservicesMicroservices C#Docker .NETKubernetes .NETgRPC .NETRabbitMQ .NETService communicationDistributed systems .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.

  • Nach fachlichen Fähigkeiten schneiden, nicht nach technischen Schichten
  • Keine gemeinsame Datenbank zwischen Services — jeder Service besitzt seine Daten
  • Verträge klar definieren und versionieren
  • Beim Herauslösen aus dem Monolith das Strangler-Fig-Pattern anwenden
  • Ein Team besitzt einen oder mehrere Services, niemals umgekehrt

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:

  • Bestellservice — besitzt den Bestelllebenszyklus, Validierungsregeln, Preisgestaltung
  • Inventarservice — besitzt Lagerbestände, Reservierungen, Lagerort-Zuordnung
  • Zahlungsservice — besitzt Zahlungsabwicklung, Erstattungslogik, Buchungseinträge

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:

csharp
// Program.cs — YARP-basiertes API Gateway
var builder = WebApplication.CreateBuilder(args);

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

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("default", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(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

csharp
// Typisierter HTTP-Client mit Resilienz-Policies
builder.Services.AddHttpClient<IOrderServiceClient, OrderServiceClient>(client =>
{
    client.BaseAddress = new Uri("https://order-service:5001");
    client.Timeout = TimeSpan.FromSeconds(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

csharp
// inventory.proto
syntax = "proto3";

option csharp_namespace = "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 = 1;
  string warehouse_id = 2;
}

message StockResponse {
  string product_id = 1;
  int32 available_quantity = 2;
  bool is_available = 3;
}

message ReserveRequest {
  string order_id = 1;
  repeated ReserveItem items = 2;
}

message ReserveItem {
  string product_id = 1;
  int32 quantity = 2;
}

message ReserveResponse {
  bool success = 1;
  string reservation_id = 2;
  string failure_reason = 3;
}

message StockSubscription {
  repeated string product_ids = 1;
}

message StockUpdate {
  string product_id = 1;
  int32 new_quantity = 2;
  string timestamp = 3;
}
csharp
// 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 ?? 0,
            IsAvailable = (stock?.Quantity ?? 0) > 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.

csharp
// 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(
            "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,
                "Unzureichender Bestand für Bestellung {OrderId}", order.OrderId);

            await context.Publish(new InventoryReservationFailedEvent
            {
                OrderId = order.OrderId,
                Reason = ex.Message
            });
        }
    }
}

// MassTransit-Registrierung
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderCreatedConsumer>();

    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host("rabbitmq", "/", h =>
        {
            h.Username("guest");
            h.Password("guest");
        });

        cfg.ReceiveEndpoint("inventory-order-created", e =>
        {
            e.ConfigureConsumer<OrderCreatedConsumer>(ctx);
            e.UseMessageRetry(r => r.Intervals(
                TimeSpan.FromSeconds(1),
                TimeSpan.FromSeconds(5),
                TimeSpan.FromSeconds(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.

  • Bounded Contexts durch Domänenanalyse identifizieren
  • Gemeinsamen Datenzugriff in modulspezifische Repositories aufteilen
  • Interne Schnittstellen zwischen Modulen einführen
  • Integrationstests an Modulgrenzen hinzufügen

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.

  • Mit einem Nur-Lese-Service starten (geringeres Risiko)
  • Beide Pfade parallel betreiben und Ergebnisse vergleichen
  • Schreiboperationen schrittweise umstellen
  • Den Monolith-Code für das migrierte Modul als Fallback behalten

Phase 3: Datenmigration

Das ist der schwierigste Teil. Jeder Service muss seinen eigenen Datenspeicher besitzen.

  • Change Data Capture (CDC) für die Synchronisation während der Übergangsphase nutzen
  • Eventual Consistency zwischen Service-Grenzen akzeptieren
  • Kompensierende Transaktionen für serviceübergreifende Operationen implementieren
  • Im Endzustand niemals eine Datenbank zwischen zwei Services teilen

Phase 4: Umstellung abschließen

  • Den Monolith-Code für das migrierte Modul entfernen
  • Gemeinsam genutzte Datenbanktabellen stilllegen
  • Monitoring und Alerting für die neue Topologie aktualisieren
  • Den neuen Service-Vertrag und die Verantwortlichkeit dokumentieren

Containerisierung und Orchestrierung

Docker-Konfiguration

Ein gut strukturiertes Dockerfile nutzt Multi-Stage-Builds, um Produktions-Images minimal zu halten:

dockerfile
# 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

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 und Resilienz

Jeder Microservice muss Gesundheitsinformationen bereitstellen. Ohne diese können Orchestratoren keine intelligenten Routing- oder Neustart-Entscheidungen treffen.

csharp
// Program.cs — Health-Check-Konfiguration
builder.Services.AddHealthChecks()
    .AddNpgSql(
        builder.Configuration.GetConnectionString("OrderDb")!,
        name: "postgresql",
        tags: new[] { "db", "ready" })
    .AddRabbitMQ(
        new Uri("amqp://guest:guest@rabbitmq:5672"),
        name: "rabbitmq",
        tags: new[] { "messaging", "ready" })
    .AddCheck<OrderProcessingHealthCheck>(
        "order-processing",
        tags: new[] { "custom", "ready" });

var app = builder.Build();

// Liveness: Lebt der Prozess?
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // Keine Checks, bestätigt nur, dass die App antwortet
});

// Readiness: Kann dieser Service Traffic verarbeiten?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
csharp
// 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(30), cancellationToken);

        if (stuckOrders > 50)
            return HealthCheckResult.Unhealthy(
                $"{stuckOrders} Bestellungen seit >30 Min in Verarbeitung blockiert");

        if (stuckOrders > 10)
            return HealthCheckResult.Degraded(
                $"{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

csharp
// Program.cs — OpenTelemetry-Setup
builder.Services.AddOpenTelemetry()
    .ConfigureResource(res => res
        .AddService(
            serviceName: "OrderService",
            serviceVersion: "1.0.0"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddGrpcClientInstrumentation()
        .AddSource("MassTransit")
        .AddOtlpExporter(opt =>
        {
            opt.Endpoint = new Uri("http://otel-collector:4317");
        }))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddMeter("OrderService.Metrics")
        .AddOtlpExporter(opt =>
        {
            opt.Endpoint = new Uri("http://otel-collector:4317");
        }));

Strukturiertes Logging mit Korrelation

csharp
// Serilog-Konfiguration mit Trace-Korrelation
builder.Host.UseSerilog((context, config) => config
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .Enrich.WithProperty("ServiceName", "OrderService")
    .Enrich.WithSpanId()
    .Enrich.WithTraceId()
    .WriteTo.Console(new JsonFormatter())
    .WriteTo.Seq("http://seq:5341"));

Was gemessen werden sollte

Die drei Säulen der Observability dienen jeweils einem anderen Zweck:

  • Logs — diskrete Ereignisse. Verwenden Sie strukturiertes Logging mit Korrelations-IDs, damit Sie eine Anfrage über Services hinweg nachverfolgen können.
  • Metriken — aggregierte Messungen. Verfolgen Sie Anfrageraten, Fehlerraten, Latenz-Perzentile (p50, p95, p99), Warteschlangentiefen und Auslastung.
  • Traces — die vollständige Reise einer Anfrage. Jeder serviceübergreifende Aufruf sollte Trace-Kontext weitergeben, damit Sie die Aufrufkette rekonstruieren können.

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:

  • Mehrere Teams eine unabhängige Release-Kadenz brauchen
  • Verschiedene Domänen unterschiedliche Skalierungsprofile haben
  • Die Kosten der Monolith-Evolution nicht mehr tragbar sind
  • Die Plattformreife vorhanden ist, um verteilte Systeme zu betreiben
  • Regulatorische oder Compliance-Anforderungen Isolation verlangen

Sie sind nicht gerechtfertigt, wenn:

  • Sie ein kleines Team haben (unter 5-8 Entwickler)
  • Die Domäne noch nicht gut verstanden ist
  • Sie Microservices nutzen wollen, weil sie modern klingen
  • CI/CD, Monitoring oder Container-Orchestrierung nicht vorhanden sind

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

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen