.NET Core

CQRS und MediatR in .NET: Trennung von Commands und Queries

14 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
CQRS .NETMediatRCommand Query Separation.NET mediator patternCQRS tutorialMediatR handlersEvent sourcing .NET.NET architecture patterns

CQRS (Command Query Responsibility Segregation) ist ein Architekturmuster, das Schreiboperationen (Commands) von Leseoperationen (Queries) in getrennte Modelle aufteilt. Im .NET-Umfeld hat sich MediatR als De-facto-Standard etabliert, um diese Trennung mit expliziten Handlern, Pipeline Behaviors und einem sauberen In-Process-Messaging-Ansatz umzusetzen. Dieser Artikel fuehrt durch das Muster von Anfang bis Ende -- mit echtem Code, Teststrategien und Erfahrungen aus produktiven Systemen.

Warum CQRS?

Die meisten Anwendungen beginnen mit einem einzigen Modell fuer Lese- und Schreiboperationen. Bei einfachem CRUD funktioniert das gut, doch mit wachsender Domaene entstehen Spannungen:

  1. Unterschiedliche Optimierungsbeduerfnisse -- Die Leseseite profitiert von denormalisierten Projektionen und Caching, waehrend die Schreibseite strikte Validierung und transaktionale Konsistenz braucht. Ein einzelnes Modell erzwingt Kompromisse auf beiden Seiten.
  2. Klare Absicht -- Wenn ein Entwickler CreateOrderCommand oeffnet, ist die Absicht sofort erkennbar. Vergleichen Sie das mit einem generischen OrderService mit 30 Methoden, die Lesen, Schreiben und Seiteneffekte vermischen.
  3. Unabhaengige Skalierbarkeit -- In vielen Systemen ueberwiegen Leseoperationen die Schreiboperationen um den Faktor 10 oder mehr. CQRS erlaubt es, jede Seite unabhaengig zu skalieren.
  4. Event-Driven-Kompatibilitaet -- CQRS passt natuerlich zu event-getriebenen Architekturen und Event Sourcing, bei dem die Schreibseite Events produziert und die Leseseite daraus Projektionen aufbaut.

In domain-lastigen Projekten, an denen ich mitgearbeitet habe, war der groesste Gewinn nicht die Performance -- es war die Klarheit fuer die Entwickler. Neue Teammitglieder konnten sich anhand der Absicht durch die Codebasis navigieren, statt zu raten, welche Service-Methode was tut.

Grundkonzepte

Commands

Commands repraesentieren die Absicht, den Systemzustand zu aendern. Sie werden im Imperativ benannt: CreateOrder, CancelSubscription, UpdateShippingAddress. Ein Command ist entweder erfolgreich oder schlaegt fehl -- er sollte keine umfangreichen Daten zurueckgeben.

csharp
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderItemDto> Items,
    string ShippingAddress
) : IRequest<CreateOrderResult>;

public record CreateOrderResult(
    Guid OrderId,
    string Status
);

Queries

Queries rufen Daten ohne Seiteneffekte ab. Sie werden beschreibend benannt: GetOrderById, ListActiveSubscriptions. Queries geben DTOs oder Read-Modelle zurueck, die fuer den Konsumenten optimiert sind.

csharp
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDetailDto>;

public record OrderDetailDto(
    Guid OrderId,
    string CustomerName,
    List<OrderItemDto> Items,
    decimal TotalAmount,
    string Status,
    DateTime CreatedAt
);

Handler

Jeder Command oder Query hat genau einen Handler. So bleibt jeder Handler auf eine einzige Verantwortlichkeit fokussiert.

csharp
public class CreateOrderCommandHandler
    : IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        var order = Order.Create(
            request.CustomerId,
            request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)),
            request.ShippingAddress
        );

        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.CommitAsync(cancellationToken);

        return new CreateOrderResult(order.Id, order.Status.ToString());
    }
}
csharp
public class GetOrderByIdQueryHandler
    : IRequestHandler<GetOrderByIdQuery, OrderDetailDto>
{
    private readonly IReadOnlyOrderRepository _readRepository;

    public GetOrderByIdQueryHandler(IReadOnlyOrderRepository readRepository)
    {
        _readRepository = readRepository;
    }

    public async Task<OrderDetailDto> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        var dto = await _readRepository.GetOrderDetailAsync(
            request.OrderId, cancellationToken);

        return dto ?? throw new OrderNotFoundException(request.OrderId);
    }
}

Pipeline Behaviors

Eines der maechtigsten Features von MediatR sind Pipeline Behaviors. Sie funktionieren wie Middleware und umschliessen jede Anfrage, die durch den Mediator fliesst. Hier gehoeren Querschnittsbelange hin -- nicht in die Handler.

Validierungs-Behavior

csharp
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken))))
            .SelectMany(result => result.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Logging-Behavior

csharp
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Verarbeite {RequestName}: {@Request}", requestName, request);

        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        _logger.LogInformation(
            "{RequestName} verarbeitet in {ElapsedMs}ms",
            requestName, stopwatch.ElapsedMilliseconds);

        return response;
    }
}

Performance-Monitoring-Behavior

csharp
public class PerformanceBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<PerformanceBehavior<TRequest, TResponse>> _logger;
    private const int WarningThresholdMs = 500;

    public PerformanceBehavior(
        ILogger<PerformanceBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        if (stopwatch.ElapsedMilliseconds > WarningThresholdMs)
        {
            _logger.LogWarning(
                "Langlaufende Anfrage: {RequestName} ({ElapsedMs}ms) - {@Request}",
                typeof(TRequest).Name,
                stopwatch.ElapsedMilliseconds,
                request);
        }

        return response;
    }
}

Registrierung und Setup

Alles im DI-Container zusammenfuehren:

csharp
// Program.cs oder Startup.cs
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);

    // Pipeline Behaviors werden in Registrierungsreihenfolge ausgefuehrt
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
});

// FluentValidation-Validatoren registrieren
builder.Services.AddValidatorsFromAssembly(typeof(CreateOrderCommand).Assembly);

Ein minimaler Controller sieht dann so aus:

csharp
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken ct)
    {
        var result = await _mediator.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
        return Ok(result);
    }
}

Einfuehrung in Event Sourcing

CQRS und Event Sourcing werden oft in einem Atemzug genannt, und das hat gute Gruende -- sie ergaenzen sich natuerlich, sind aber nicht dasselbe.

In einem klassischen CQRS-Setup speichert das Write-Modell den aktuellen Zustand in einer Datenbank. Bei Event Sourcing wird statt des aktuellen Zustands die Abfolge der Ereignisse gespeichert, die zu diesem Zustand gefuehrt haben. Jedes OrderCreated, ItemAdded, OrderShipped-Event wird an einen Event Store angehaengt, und der aktuelle Zustand wird durch Wiedergabe dieser Events rekonstruiert.

csharp
// Domain-Events des Order-Aggregats
public record OrderCreatedEvent(
    Guid OrderId,
    Guid CustomerId,
    DateTime CreatedAt
) : INotification;

public record OrderItemAddedEvent(
    Guid OrderId,
    Guid ProductId,
    int Quantity,
    decimal UnitPrice
) : INotification;

public record OrderShippedEvent(
    Guid OrderId,
    string TrackingNumber,
    DateTime ShippedAt
) : INotification;

Die Leseseite abonniert diese Events und baut optimierte Projektionen auf:

csharp
public class OrderSummaryProjection
    : INotificationHandler<OrderCreatedEvent>,
      INotificationHandler<OrderShippedEvent>
{
    private readonly IOrderSummaryStore _store;

    public OrderSummaryProjection(IOrderSummaryStore store) => _store = store;

    public async Task Handle(
        OrderCreatedEvent notification,
        CancellationToken cancellationToken)
    {
        await _store.CreateSummaryAsync(new OrderSummary
        {
            OrderId = notification.OrderId,
            CustomerId = notification.CustomerId,
            Status = "Created",
            CreatedAt = notification.CreatedAt
        }, cancellationToken);
    }

    public async Task Handle(
        OrderShippedEvent notification,
        CancellationToken cancellationToken)
    {
        await _store.UpdateStatusAsync(
            notification.OrderId, "Shipped", cancellationToken);
    }
}

Der entscheidende Vorteil: Sie erhalten einen vollstaendigen Audit-Trail kostenlos und koennen neue Read-Modelle nachtraeglich erstellen, indem Sie Events erneut abspielen. Der Kompromiss liegt in erhoehter Komplexitaet bei der Event-Versionierung, beim Snapshotting fuer Performance und bei der Eventual Consistency zwischen Schreib- und Leseseite.

In domain-lastigen Projekten, an denen ich mitgearbeitet habe, hat sich Event Sourcing besonders fuer Auditing und Debugging als wertvoll erwiesen. Die Moeglichkeit, die exakte Abfolge von Events nachzuspielen, die zu einem Bug gefuehrt hat, hat Stunden an Fehlersuche gespart.

CQRS-Handler testen

Eines der staerksten Argumente fuer CQRS mit MediatR ist die Testbarkeit. Jeder Handler ist eine eigenstaendige Klasse mit expliziten Abhaengigkeiten, was Unit-Tests unkompliziert macht.

Command-Handler testen

csharp
public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IOrderRepository> _repoMock;
    private readonly Mock<IUnitOfWork> _uowMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _repoMock = new Mock<IOrderRepository>();
        _uowMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(
            _repoMock.Object, _uowMock.Object);
    }

    [Fact]
    public async Task Handle_GueltigerCommand_ErstelltBestellungUndCommitted()
    {
        // Arrange
        var command = new CreateOrderCommand(
            CustomerId: Guid.NewGuid(),
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 2, UnitPrice: 29.99m)
            },
            ShippingAddress: "Musterstrasse 42, Berlin"
        );

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        result.OrderId.Should().NotBeEmpty();
        result.Status.Should().Be("Pending");

        _repoMock.Verify(
            r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
            Times.Once);
        _uowMock.Verify(
            u => u.CommitAsync(It.IsAny<CancellationToken>()),
            Times.Once);
    }
}

Pipeline-Behavior testen

csharp
public class ValidationBehaviorTests
{
    [Fact]
    public async Task Handle_UngueltigeAnfrage_WirftValidationException()
    {
        // Arrange
        var validator = new InlineValidator<CreateOrderCommand>();
        validator.RuleFor(x => x.Items).NotEmpty();

        var behavior = new ValidationBehavior<CreateOrderCommand, CreateOrderResult>(
            new[] { validator });

        var invalidCommand = new CreateOrderCommand(
            Guid.NewGuid(), new List<OrderItemDto>(), "Musterstrasse 42");

        // Act & Assert
        await Assert.ThrowsAsync<ValidationException>(
            () => behavior.Handle(
                invalidCommand,
                () => Task.FromResult(new CreateOrderResult(Guid.NewGuid(), "ok")),
                CancellationToken.None));
    }

    [Fact]
    public async Task Handle_GueltigeAnfrage_RuftNextAuf()
    {
        // Arrange
        var behavior = new ValidationBehavior<CreateOrderCommand, CreateOrderResult>(
            Enumerable.Empty<IValidator<CreateOrderCommand>>());

        var expected = new CreateOrderResult(Guid.NewGuid(), "Pending");
        var nextCalled = false;

        // Act
        var result = await behavior.Handle(
            new CreateOrderCommand(Guid.NewGuid(), new List<OrderItemDto>
            {
                new(Guid.NewGuid(), 1, 10m)
            }, "Adresse"),
            () =>
            {
                nextCalled = true;
                return Task.FromResult(expected);
            },
            CancellationToken.None);

        // Assert
        nextCalled.Should().BeTrue();
        result.Should().Be(expected);
    }
}

Integrationstests mit MediatR

csharp
public class OrderIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public OrderIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task BestellungErstellen_EndToEnd_GibtErstellteBestellungZurueck()
    {
        // Arrange
        using var scope = _factory.Services.CreateScope();
        var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

        var command = new CreateOrderCommand(
            Guid.NewGuid(),
            new List<OrderItemDto> { new(Guid.NewGuid(), 3, 15.00m) },
            "Berliner Allee 78"
        );

        // Act
        var result = await mediator.Send(command);

        // Assert -- ueber die Query-Seite verifizieren
        var order = await mediator.Send(new GetOrderByIdQuery(result.OrderId));
        order.Should().NotBeNull();
        order.TotalAmount.Should().Be(45.00m);
    }
}

Wann CQRS uebertrieben ist

CQRS bringt echte strukturelle Komplexitaet mit sich. Hier ein ehrliches Entscheidungsraster:

CQRS ueberspringen, wenn:

  • Die Anwendung einfaches CRUD mit minimalen Geschaeftsregeln ist
  • Das Team klein ist und keine architektonischen Grenzen braucht, um organisiert zu bleiben
  • Lese- und Schreibkomplexitaet ungefaehr gleich sind -- es gibt keine Asymmetrie zum Ausnutzen
  • Sie einen Prototyp oder MVP bauen, bei dem Liefergeschwindigkeit wichtiger ist als langfristige Struktur
  • Die Domaene weniger als 5-10 Aggregate hat

CQRS in Betracht ziehen, wenn:

  • Lese- und Schreibmodelle grundlegend unterschiedliche Formen oder Performance-Anforderungen haben
  • Komplexe Geschaeftsablaeufe mit vielen Validierungsregeln und Zustandsuebergaengen vorliegen
  • Mehrere Teams an derselben Domaene arbeiten und klare Grenzen brauchen
  • Ein Audit-Trail benoetigt wird oder Event Sourcing geplant ist
  • Die Leseseite denormalisierte Views, Suchindizes oder materialisierte Projektionen erfordert

In domain-lastigen Projekten, an denen ich mitgearbeitet habe, lag der Kipppunkt meist bei etwa 15-20 Command/Query-Paaren. Darunter war der Overhead durch separate Modelle, Handler und Pipeline-Infrastruktur den Klarheitsgewinn nicht wert.

Haeufige CQRS-Fehler

1. Umfangreiche Daten aus Commands zurueckgeben

Commands sollten minimale Informationen zurueckgeben -- eine ID, einen Status oder gar nichts. Wenn Ihr CreateOrderCommand das vollstaendige Bestelldetail-DTO zurueckgibt, verwischen Sie die Lese-/Schreibgrenze.

csharp
// Vermeiden
public record CreateOrderCommand(...) : IRequest<OrderDetailDto>;

// Besser
public record CreateOrderCommand(...) : IRequest<CreateOrderResult>;
public record CreateOrderResult(Guid OrderId, string Status);

2. Ueberladene Handler

Wenn Ihr Handler 200 Zeilen lang ist, macht er zu viel. Verlagern Sie Domaenenlogik in Domain-Entities oder Domain-Services. Der Handler ist ein Orchestrator, kein Container fuer Geschaeftslogik.

csharp
// Vermeiden: Geschaeftslogik im Handler
public async Task<Result> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
    // 150 Zeilen Validierung, Berechnung und Zustandsverwaltung...
}

// Besser: Handler orchestriert, Domain-Modell enthaelt die Logik
public async Task<Result> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
    var order = Order.Create(cmd.CustomerId, cmd.Items, cmd.ShippingAddress);
    await _repository.AddAsync(order, ct);
    await _unitOfWork.CommitAsync(ct);
    return new Result(order.Id);
}

3. MediatR fuer alles verwenden

MediatR ist ein Werkzeug fuer In-Process-Messaging. Verwenden Sie es nicht als Ersatz fuer einfache Methodenaufrufe zwischen Klassen im selben Bounded Context. Wenn Klasse A immer Klasse B aufruft, ist eine direkte Abhaengigkeit klarer als die Weiterleitung ueber einen Mediator.

4. Die Pipeline umgehen

Validierung, Logging und Fehlerbehandlung direkt in jeden Handler einzubauen verfehlt den Zweck. Nutzen Sie Pipeline Behaviors, um Querschnittsbelange zu zentralisieren. Andernfalls entsteht duplizierter Boilerplate-Code in Dutzenden von Handlern.

5. Eventual Consistency ignorieren

Wenn Ihr Read-Modell aus Events oder Projektionen aufgebaut wird, hinkt es dem Write-Modell hinterher. Ihre Oberflaeche und API-Konsumenten muessen darauf ausgelegt sein. Ein 200-Status von einem Command bedeutet nicht, dass die Leseseite bereits aktualisiert ist.

Fazit

CQRS mit MediatR kann Klarheit, Testbarkeit und Skalierbarkeit in komplexen .NET-Anwendungen erheblich verbessern. Das Muster glaenzt, wenn eine echte Asymmetrie zwischen Lesen und Schreiben besteht, wenn die Domaene reichhaltig genug ist, um getrennte Modelle zu rechtfertigen, und wenn das Team explizite Absichtserklaerugen gegenueber impliziten Konventionen bevorzugt. Es ist aber keine Standardwahl -- es ist eine architektonische Investition, die durch echte Komplexitaet motiviert sein sollte.

Fangen Sie klein an: Fuehren Sie MediatR fuer einige Commands im komplexesten Teil Ihrer Domaene ein. Wenn der Klarheitsgewinn den Overhead rechtfertigt, erweitern Sie von dort aus. Falls nicht, haben Sie etwas Wertvolles ueber Ihr System gelernt.

Gerne unterstuetze ich bei der Bewertung, ob CQRS zu Ihrer Domaene passt, und bei der Planung einer pragmatischen Einfuehrungsstrategie.

Verwandte Artikel

Haben Sie ein Flutter-Projekt?

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

Kontakt aufnehmen