Playbook
Wie funktioniert eine CQRS (Command Query Responsibility Segregation)-Pipeline? Anatomie von Command- und Query-Flows (Wie CQRS Intern Funktioniert Command Und Query Pipelines)
Was ist eine CQRS Request Pipeline? Wie durchläuft eine HTTP-Anfrage Controller, MediatR, Pipeline Behaviors, Handler, Outbox und Read Model?
Was passiert, wenn eine HTTP-Anfrage ein System erreicht? Wo läuft Validation, wann wird eine Transaktion geöffnet und welche Schicht wendet Domain-Regeln an? In einem CQRS-System unterscheidet sich diese Reise von einer klassischen CRUD-Architektur. Dieser Artikel verfolgt eine Anfrage von der API zur Datenbank und anschließend zum Read Model.
Für Leser, die CQRS noch nicht kennen, das kürzeste Glossar: Ein Command drückt die Absicht aus, das System zu ändern; eine Query fragt nach Information, ohne sie zu ändern; ein Handler verarbeitet die Anfrage. Ein Mediator wie MediatR lässt einen Controller die Anfrage in den richtigen Flow senden, statt den Handler direkt zu kennen.
POST /orders
↓
Controller
↓
Mediator.Send(command)
↓
Pipeline Behaviors
↓
Command Handler
↓
Aggregate + Repository
↓
Database
Was ist ein Pipeline Behavior?
Ein Pipeline Behavior ist eine gemeinsame technische Kontrolle, die nicht in jedem Command wiederholt werden soll. Keine davon ist eine Fachregel; Fachregeln leben im Handler und Aggregate.
ValidationBehavior
↓
AuthorizationBehavior
↓
LoggingBehavior
↓
MetricsBehavior
↓
RetryBehavior (nur sichere Operationen)
↓
TransactionBehavior
↓
Handler
Die Transaktion wird erst spät geöffnet, weil ungültige, nicht berechtigte oder früh ablehnbare Anfragen keine Datenbankverbindung und Locks halten sollen. Wenn der Handler erreicht wird, ist das System bereit, die Fachentscheidung atomar anzuwenden.
Ein Aggregate schützt Fachregeln, die niemals verletzt werden dürfen—seine Invarianten. Wenn eine abgeschlossene Bestellung zum Beispiel nicht erneut storniert werden darf, gehört diese Regel in das Order-Aggregate und nicht in einen Controller.
Die Query Side nimmt einen anderen Weg. Für dieselbe Bestellung braucht ein Nutzer vielleicht nicht das gesamte Domain-Objekt, sondern eine kompakte Ansicht für den Screen:
GET /orders
↓
Authorization
↓
Cache (wenn passend)
↓
Read Database / Search Index
↓
OrderSummary DTO
↓
Frontend
Konzepte bei der ersten Verwendung
📦 Handler
Führt den Anwendungsablauf für einen Command oder eine Query aus.
📦 Mediator
Lässt einen Controller eine Anfrage an den richtigen Handler senden, ohne ihn direkt zu kennen.
📦 Pipeline Behavior
Eine gemeinsame technische Schicht für jede Anfrage, etwa Validation, Authorization oder Logging.
📦 Repository
Die Anwendungsgrenze, die ein Aggregate lädt und dauerhaft speichert.
📦 DTO
Trägt nicht das gesamte Domain-Modell, sondern die Daten, die ein Screen benötigt.
Jeder Begriff hat eine Motivation. Idempotency verhindert eine zweite Bestellung, wenn dieselbe Anfrage zweimal eintrifft. Outbox macht Datenbank-Write und Event-Publish innerhalb derselben Transaktionsgrenze sicher. Beides ist keine technische Dekoration der Pipeline, sondern eine Antwort auf Fehlerfälle in Production.
Beim Handler-Beispiel ist diese Unterscheidung wichtig: Der Handler wendet nur die Fachentscheidung an. Validation, Authorization und Logging liegen nicht in ihm; Pipeline Behaviors behandeln sie. So bleibt der Handler klein und jede Anfrage durchläuft dieselben Sicherheits- und Observability-Regeln.
CQRS wird oft auf zwei Ordner, einige MediatR-Handler und vielleicht einen Cache reduziert. Dieses Bild führt in die Irre. CQRS teilt nicht primär Code auf. Es trennt Entscheidungen, die den Zustand eines Systems verändern, von Anfragen, die Informationen über diesen Zustand benötigen.
Dies ist das zweite Kapitel von CQRS- Anatomie von Entscheidungen. Im ersten Kapitel geht es darum, warum Trennung notwendig wird. Hier betrachten wir den Mechanismus: Welche Stationen durchläuft ein Command, warum sollte eine Query nicht denselben Weg nehmen und wann wird ein Read Model zu einem eigenen System?
Eine Anfrage, zwei unterschiedliche Absichten
PlaceOrder ist ein Command. Er möchte eine neue Tatsache im System erzeugen. Er führt Regeln aus, benötigt Berechtigung, öffnet eine Transaktion und muss auditierbar sein.
GetOrderSummary ist eine Query. Sie erzeugt keine neue Tatsache. Sie möchte eine schmale, schnelle und für einen Bildschirm geeignete Ansicht liefern. Sie muss weder das Aggregate laden noch Domain-Regeln ausführen oder mit Write-Side-Locks konkurrieren.
Diese Unterscheidung hat eine algorithmische Folge. Kosten auf der Command-Seite werden bewusst für Validierung und Konsistenz akzeptiert. Kosten auf der Query-Seite sollten mit den für die Ansicht benötigten Zeilen und Feldern wachsen, nicht mit dem gesamten Domain-Graphen.
Die Command-Pipeline: ein sicherer Weg für Entscheidungen
Die Command-Seite ist nicht nur ein Handler-Aufruf. Bevor eine Entscheidung die Fachlogik erreicht, sollte sie gemeinsame Kontrollen durchlaufen.
API
→ Authentication / Authorization
→ Validation
→ Idempotency check
→ Transaction
→ Command Handler
→ Aggregate + Domain Rules
→ Persist + Outbox
→ Commit
Pseudocode:
handle(command):
authorize(command.actor)
validate(command)
return idempotency.execute(command.key):
begin transaction
aggregate = repository.load(command.aggregateId)
aggregate.apply(command)
repository.save(aggregate)
outbox.store(aggregate.domainEvents)
commit transaction
Jeder Schritt besitzt genau eine Verantwortung. Validation verwirft eine ungültige Form früh, ersetzt aber keine Fachregel. Authorization prüft, ob der Aufrufer die Entscheidung treffen darf. Das Aggregate schützt Invarianten. Die Outbox macht den gespeicherten Zustand und das zu veröffentlichende Event zum Teil derselben Transaktionsgrenze.
Mediator macht diesen Ablauf in C# praktisch:
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<OrderLine> Lines) : IRequest<OrderId>;
public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrder, OrderId>
{
public async Task<OrderId> Handle(PlaceOrder command, CancellationToken ct)
{
var order = Order.Place(command.CustomerId, command.Lines);
await repository.AddAsync(order, ct);
await outbox.AddAsync(order.DomainEvents, ct);
return order.Id;
}
}
Der Handler bleibt klein, weil Audit, Messung, Validierung und Retry-Policies in Pipeline Behaviors liegen. Der Gewinn: Kein Handler muss dieselbe Zeremonie wiederholen. Die Kosten: ein zusätzlicher Aufrufpfad. Deshalb sollten Reihenfolge und Wirkung der Behaviors dokumentiert und beobachtbar sein.
Die Query-Pipeline: nur lesen, was gebraucht wird
Eine Query beginnt mit einer anderen Frage: Welche Information braucht der Nutzer innerhalb welches Latenzbudgets? Die Antwort ist ein Use Case, nicht das Domain-Modell.
API
→ View authorization
→ Query Handler
→ Read model / cache / search index
→ DTO shaped for the screen
Für eine Bestellliste das Order-Aggregate, Kundenbeziehungen und Lagerregeln zu laden, ist meist unnötig. Ein Query-Handler wählt genau die Felder aus, die die Ansicht benötigt. Seine Kosten werden dadurch von den zurückgegebenen Zeilen und Spalten begrenzt statt von einem ganzen Objektgraphen.
public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummaryDto?>;
public sealed class GetOrderSummaryHandler : IRequestHandler<GetOrderSummary, OrderSummaryDto?>
{
public Task<OrderSummaryDto?> Handle(GetOrderSummary query, CancellationToken ct) =>
readDb.OrderSummaries
.Where(x => x.Id == query.OrderId)
.Select(x => new OrderSummaryDto(x.Id, x.Status, x.Total, x.UpdatedAt))
.SingleOrDefaultAsync(ct);
}
Ein DTO ist keine Dekoration. Es ist der Lesevertrag. Das Read Model kann sich mit einem Bildschirm verändern; das Command Model kann sich mit Fachregeln verändern. Beide Änderungen müssen nicht im gleichen Tempo erfolgen.
Logisches und physisches CQRS
Für die meisten Teams ist logisches CQRS der erste sinnvolle Schritt: Commands und Queries sind im Code getrennt, teilen aber eine relationale Datenbank. ACID-Transaktionen bleiben einfach, die Betriebskosten niedrig und das Debugging direkt.
Physisches CQRS verschiebt ein Read Model in einen eigenen Datenspeicher, Cache oder Suchindex. Das ermöglicht unabhängige Skalierung der Read Side, bringt aber Erwartungen an Datenaktualität, Projection-Fehler und Rebuild-Prozesse mit. Physische Trennung ist keine Performance-Ästhetik, sondern eine Antwort auf einen nachgewiesenen Engpass.
| Entscheidung | Gewinn | Akzeptierte Kosten |
|---|---|---|
| Logisches CQRS | Niedrige Betriebskosten, starke Konsistenz | Lesen und Schreiben teilen Infrastruktur |
| Physisches CQRS | Unabhängige Skalierung, ansichtsspezifische Modelle | Eventual Consistency und Projection-Betrieb |
Vom Write Model zum Read Model: CDC oder Outbox?
Bei physischer Trennung ist entscheidend, wie Daten fließen. Change Data Capture kann Datenbank-Logs beobachten und eignet sich für Replay bestehender Änderungen ohne Anpassung der Anwendung. Der Trade-off ist eine engere Kopplung zwischen Datenbankschema und Message Contract.
Das Outbox-Pattern schreibt ein Domain Event in derselben Transaktion wie die Zustandsänderung in eine Outbox-Tabelle. Ein Relay veröffentlicht es an einen Broker; Consumer verarbeiten es idempotent. Damit wird die Lücke Datenbank-Write erfolgreich, Event-Publish fehlgeschlagen beherrschbar. Im Gegenzug werden Relay-Gesundheit, Retries, Dead Letters und Consumer-Idempotenz Teil des Designs.
Command commit
→ Outbox record
→ Relay publishes event
→ Projection consumes event
→ Read model updates
Das realistische Ziel ist kein magisches Exactly-once-Versprechen. Es ist At-least-once-Zustellung zusammen mit idempotenter Verarbeitung. Eine Projection kann eine Event-ID speichern, damit ein Duplikat die Ansicht nicht zweimal verändert.
Warum naive Lösungen brechen
- Direkt aus einem Handler an einen Broker publizieren: Die Transaktion kann zurückgerollt werden, nachdem die Nachricht bereits konsumiert wurde.
- Für jede Query Aggregate laden: Listenansichten werden durch vermeidbare Regeln, Joins und I/O teuer.
- Jede Read-Model-Verzögerung als Fehler bewerten: Manche Ansichten brauchen frische Daten, andere tolerieren Sekunden Verzögerung. Das ist eine Produktentscheidung.
- Jedes Problem mit physischem CQRS lösen: Ein weiterer Datenspeicher ist nicht nur Technologie, sondern dauerhafte operative Verantwortung.
Entscheidungs-Checkliste
- Sind Erfolgskriterien und Idempotency Keys eines Commands explizit?
- Wird jede Invariante an der Aggregate-Grenze geschützt?
- Ist jede Query nach ihrem Use-Case-DTO statt nach dem Domain-Graphen geformt?
- Ist die Aktualität des Read Models als Produkterwartung beschrieben?
- Kann eine Projection sicher neu aufgebaut, beobachtet und verifiziert werden?
CQRS ist keine automatische Eintrittskarte zu unbegrenzter Skalierung. Richtig eingesetzt macht es Entscheidungen zu einer bewussten Pipeline und Reads zu Modellen für einen tatsächlichen Bedarf. Das System wird sichtbarer und widerstandsfähiger gegenüber Veränderung.
Im nächsten Kapitel überschreiten wir die Service-Grenze: CQRS mit Brokern, Projections und Wiederherstellungsstrategien in verteilten Systemen.
Was ist ein Read Model?
Ein Read Model ist keine Kopie des Write-Side-Domain-Modells. Es ist eine Ansicht, die für den Bedarf eines Screens vorbereitet wurde.
Orders (Write Model)
Id | CustomerId | Status | Lines | Rules
↓ Projection
OrderSummary (Read Model)
Id | CustomerName | Status | Total | BadgeColor
CDC und Outbox im Vergleich
| Kriterium | CDC | Outbox |
|---|---|---|
| Domain-Event-Intention | Indirekt | Explizit |
| Datenbank-Schema-Kopplung | Hoch | Niedriger |
| Replay | Stark | Stark |
| Kontrolle über Event Payload | Begrenzt | Voll |
| Microservice-Kommunikation | Kontextabhängig | Sehr geeignet |
Warum naive Lösungen in Production brechen
Handler
→ DbContext.SaveChanges()
→ Message publish
Wenn der zweite Schritt fehlschlägt, sind die Daten geschrieben, das Event ist aber verloren. Oder eine Transaktion wird zurückgerollt, nachdem dieser Aufruf bereits eine externe Nebenwirkung ausgelöst hat:
await emailService.Send(...)
Die Outbox legt deshalb die persistierte Änderung und das zu veröffentlichende Event in dieselbe Transaktionsgrenze. Nebenwirkungen wie E-Mail werden nach dem Commit von retry-sicheren Consumern behandelt.
Wie fließt eine echte Anfrage?
POST /orders
↓
Controller
↓
Mediator.Send()
↓
Validation → Authorization → Logging → Metrics → Transaction
↓
Handler
↓
Aggregate
↓
Repository + Outbox
↓
Commit
↓
Relay → Broker / Kafka
↓
Projection
↓
Read Database
↓
GET /orders → Query Handler → DTO → Frontend
CQRS ist keine automatische Skalierung. Wenn Sie jedoch erklären können, warum jeder Schritt dieser Pipeline existiert, schafft es eine bewusste Trennung von Verantwortlichkeiten. Wenn nicht, sind Sie möglicherweise noch nicht bereit für CQRS.
Warum sollte ich diesen Flow kennen?
Wenn Sie wissen, welche Schichten eine Anfrage durchläuft, können Sie:
- Validation nicht zufällig in Controller oder Handler schreiben.
- Fachregeln aus der HTTP-Schicht heraushalten und an der Aggregate-Grenze schützen.
- Eine Transaktion nicht früher als nötig öffnen.
- Handler frei von Logging-, Authorization- und Metrics-Code halten.
- Cross-Cutting Concerns in die Pipeline verschieben.
Auch die Unterscheidung auf der Query Side ist praktisch:
Bestelldetail
→ Order Aggregate
→ das richtige Modell für Regeln und Verhalten
Bestellliste
→ OrderSummaryDto
→ eine schmale, schnelle Ansicht für den Screen
Diese Unterscheidung macht CQRS nicht zu einer Ordnerkonvention, sondern zu einer Disziplin, jede Verantwortung an der richtigen Stelle zu platzieren.
FAQ
Häufige Fragen
Was ist Handler?
Führt den Anwendungsablauf für einen Command oder eine Query aus.
Was ist Mediator?
Lässt einen Controller eine Anfrage an den richtigen Handler senden, ohne ihn direkt zu kennen.
Worum geht es in „Wie funktioniert eine CQRS (Command Query Responsibility Segregation)-Pipeline? Anatomie von Command- und Query-Flows“?
Was ist eine CQRS Request Pipeline? Wie durchläuft eine HTTP-Anfrage Controller, MediatR, Pipeline Behaviors, Handler, Outbox und Read Model?
Gelernte Engineering-Prinzipien
- CQRS sind nicht zwei Datenbanken, sondern die Anerkennung unterschiedlicher Verantwortungen von Lesen und Schreiben.
- Pipelines entfernen wiederholte Kontrollen aus Handlern und machen die Fachentscheidung sichtbar.
- Physische Trennung lohnt sich nur, wenn Aktualität, Retries und Latenz als Produktentscheidung gestaltet werden.
Weiterlesen
Weiterlesen
Verwandte Beitrage
CQRS (Command Query Responsibility Segregation) in Produktion: Konsistenz, Fehler und Wiederherstellung
Wie bleibt CQRS in Produktion sicher? Consistency Lag, Duplicate Events, Reordering, Projection Recovery, Retries, DLQs und Saga-Strategien.
Verwandte Beitrage
CQRS (Command Query Responsibility Segregation) in verteilten Systemen: Events, Broker und Projektionen
Wie funktioniert CQRS in verteilten Systemen? Ein praxisnaher Leitfaden zu Domain Events, Message Brokern, Projektionen, Outbox, Idempotenz und Eventual…
Verwandte Beitrage
Wie funktioniert ein Vertical Slice von innen?
Ein technischer Leitfaden zum inneren Ablauf eines Vertical Slice: Request, Validation, Handler, Aggregate, Outbox, Projection, Idempotenz, Performance,…