.NET Core
CQRS und MediatR in .NET: Trennung von Commands und Queries
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:
- 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.
- Klare Absicht -- Wenn ein Entwickler
CreateOrderCommandoeffnet, ist die Absicht sofort erkennbar. Vergleichen Sie das mit einem generischenOrderServicemit 30 Methoden, die Lesen, Schreiben und Seiteneffekte vermischen. - Unabhaengige Skalierbarkeit -- In vielen Systemen ueberwiegen Leseoperationen die Schreiboperationen um den Faktor 10 oder mehr. CQRS erlaubt es, jede Seite unabhaengig zu skalieren.
- 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.
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.
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.
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());
}
}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
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
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
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:
// 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:
[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.
// 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:
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
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
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
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.
// 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.
// 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
Clean Architecture in .NET: Skalierbare Projektstruktur
Wenden Sie Clean Architecture in .NET-Projekten an. Schichten, Abhängigkeiten und testbarer Code.
Dependency Injection in .NET: Grundkonzepte und Umsetzung
Verstehen und implementieren Sie Dependency Injection in .NET richtig. Service Lifetimes und Best Practices.
Microservices-Architektur mit .NET: Design und Umsetzung
Entwerfen Sie Microservices-Architektur mit .NET. Service-Kommunikation und Orchestrierung.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen