post

Commands, Events und Queries sauber trennen

Command, Event und Query sind drei Nachrichtenarten mit klarer Semantik: ein Wunsch, der abgelehnt werden kann, eine geschehene Tatsache und eine seiteneffektfreie Abfrage. Wer sie sauber benennt und trennt, gewinnt Klarheit im Modell – ganz ohne großes CQRS-Setup.

In fast jedem Workshop, den ich zu Architektur und Domänenmodellierung gebe, kommt derselbe Moment. Wir stehen vor einem Whiteboard, jemand hat ein Kästchen mit OrderService gemalt, und darin steht eine Methode updateOrder(...). Ich frage dann: „Was passiert hier eigentlich – möchte jemand etwas, oder ist gerade etwas passiert?" Meistens folgt eine Pause. Denn genau diese Unterscheidung fehlt in vielen Codebasen. Alles heißt irgendwie gleich, alles fließt irgendwie durcheinander, und niemand kann auf Anhieb sagen, ob eine Nachricht eine Bitte, eine Tatsache oder eine Frage ist.

Dabei ist die Unterscheidung weder neu noch kompliziert. Sie liegt in drei Begriffen, die ich in diesem Artikel sauber auseinanderhalten möchte: Command, Event und Query. Wer diese drei Nachrichtenarten mit klarer Semantik und disziplinierter Benennung trennt, gewinnt sofort an Modellklarheit – und zwar unabhängig davon, ob am Ende ein großes CQRS-Setup mit getrennten Datenbanken steht oder nur ein einziger Service mit einer Handvoll Handler.

Drei Nachrichten, drei Bedeutungen

Fangen wir mit dem Kern an, denn hier entscheidet sich alles. Ein Command, ein Event und eine Query sind grundverschiedene Dinge, auch wenn sie im Code oberflächlich ähnlich aussehen – am Ende ist jedes davon ein Objekt mit ein paar Feldern. Der Unterschied steckt nicht in der Struktur, sondern in der Bedeutung.

Ein Command ist ein imperativer Wunsch. „Tu X." CreateOrder, ShipProduct, CancelReservation. Ein Command drückt eine Absicht aus, die noch nicht eingetreten ist. Und das ist entscheidend: Ein Command ist eine Bitte, die abgelehnt werden kann. Er durchläuft Validierung und Geschäftsregeln, und wenn etwas nicht stimmt, scheitert er. Genau ein Handler ist zuständig, ein Command ist gerichtet – er hat einen intendierten Empfänger.

Ein Event ist das genaue Gegenteil in der Zeitachse. Es ist eine Tatsache, die bereits passiert IST. OrderCreated, ProductShipped, ItemOrdered – benannt in der Vergangenheit. Ein Event ist unveränderlich, denn man kann die Vergangenheit nicht mehr ändern. Und ein Event ist nicht ablehnbar: Man kann zu etwas Geschehenem nicht „nein" sagen. Es ist das Ergebnis eines erfolgreich verarbeiteten Commands. Während ein Command genau einen Handler hat, kann ein Event keinen, einen oder viele Konsumenten haben – es ist eine Benachrichtigung an potenziell viele.

Eine Query schließlich fragt Daten ab, ohne etwas zu verändern. GetOrderSummary. Sie gibt ein Ergebnis zurück und lässt den beobachtbaren Zustand des Systems unangetastet – sie ist frei von Seiteneffekten. Eine Query liest, idealerweise aus einer für das Lesen optimierten Sicht, und darf harmlos beliebig oft wiederholt werden.

Diese semantische Trennung ist der ganze Punkt. Bertrand Meyer hat sie vor Jahrzehnten als Command-Query Separation formuliert: Jede Methode ist entweder ein Command, der den Zustand ändert, aber nichts zurückgibt, oder eine Query, die etwas zurückgibt, aber den Zustand nicht ändert – nie beides. Das Event kommt als dritte Art hinzu, wenn wir über Zustandsänderungen nicht nur befehlen, sondern auch berichten wollen.

Der Name transportiert die Semantik

Wenn ich in Reviews auf verwaschene Modelle stoße, liegt es fast immer an der Benennung. Der Name einer Nachricht ist kein Beiwerk – er ist die Semantik, sichtbar gemacht. Ein Command im Imperativ, ein Event in der Vergangenheit. Diese Disziplin klingt banal, aber sie ist der billigste und wirksamste Hebel, den ich kenne.

Vergleichen wir die drei Arten direkt:

  • Ein Command steht im Imperativ und in der Gegenwart (CreateOrder): eine Absicht, gerichtet an genau einen Handler, die abgelehnt werden und scheitern kann.
  • Ein Event steht in der Vergangenheit (OrderCreated): eine Tatsache, unveränderlich und nicht ablehnbar, gerichtet an null bis viele Konsumenten.
  • Eine Query beginnt meist mit Get oder Find (GetOrderSummary): eine Frage, seiteneffektfrei, sie liefert Daten und verändert nichts.

Sobald ein Team diese Namenskonvention verinnerlicht hat, liest sich der Code fast von selbst. Man sieht CreateOrder und weiß: Hier will jemand etwas, das schiefgehen kann. Man sieht OrderCreated und weiß: Das ist bereits geschehen, damit kann ich rechnen. Der Name ersetzt die halbe Dokumentation.

Der Fluss: vom Wunsch zur Tatsache zur Antwort

Wie hängen die drei nun zusammen? Der typische Fluss ist erstaunlich geradlinig. Ein Command trifft ein und wird von seinem Handler geprüft. Der Handler validiert und wendet Geschäftsregeln an. Wenn alles stimmt, emittiert er ein oder mehrere Events – die Tatsachen, die aus dem erfolgreichen Command folgen. Diese Events aktualisieren dann das Read Model, also die Lesesicht. Und eine Query liest schließlich aus genau diesem Read Model, ohne den Schreibpfad überhaupt zu berühren.

Martin Fowler beschreibt das als zwei getrennte Wege: Der geänderte Zustand wird über das Command-Modell geroutet, die Anzeige kommt aus dem Query-Modell. Schreiben läuft über Command zu Event, Lesen geht direkt ans Read Model.

flowchart LR
  C[Command: CreateOrder] --> H[Command Handler]
  H -->|validate / business rules| E[Event: OrderCreated]
  E --> RM[(Read Model)]
  Q[Query: GetOrderSummary] --> RM

Man erkennt in diesem Bild die Asymmetrie, die den ganzen Ansatz ausmacht. Der Schreibpfad ist ein Prozess mit Prüfung: Command, Validierung, dann erst die Tatsache. Der Lesepfad ist eine direkte Abfrage ohne Umweg. Beide teilen sich das Read Model, aber sie nähern sich ihm von entgegengesetzten Seiten – der eine schreibend über Events, der andere lesend über Queries.

In Code gegossen

Genug Theorie – schauen wir uns an, wie das in TypeScript aussieht. Ich betone: Der folgende Code ist illustrativ, kein lauffähiges Repository. Er soll die Semantik zeigen, nicht ein Framework ersetzen. Beginnen wir mit den Nachrichtentypen selbst, modelliert als discriminated unions:

type LineItem = { sku: string; quantity: number };

type Command =
  | { type: 'CreateOrder'; orderId: string; customerId: string; items: LineItem[] };

type DomainEvent =
  | { type: 'OrderCreated'; orderId: string; customerId: string; occurredAt: Date };

type Query =
  | { type: 'GetOrderSummary'; orderId: string };

Schon an den Typen lässt sich die Semantik ablesen. Der Command steht im Imperativ. Das Event steht in der Vergangenheit und trägt ein occurredAt – den Zeitpunkt, zu dem die Tatsache eingetreten ist, denn ein Event lebt von seiner zeitlichen Verortung. Die Query beginnt mit Get und liefert erkennbar nur eine Frage.

Jetzt der Command-Handler. Er ist das Herzstück des Schreibpfads: Er prüft, entscheidet und emittiert. Wichtig ist, dass ein Command abgelehnt werden kann – und dass die Rückgabe eine Liste von Events ist, denn ein Command kann durchaus mehrere Tatsachen nach sich ziehen:

class RejectedCommand extends Error {}

function handleCreateOrder(cmd: Extract<Command, { type: 'CreateOrder' }>): DomainEvent[] {
  if (cmd.items.length === 0) {
    throw new RejectedCommand('order needs at least one item');
  }

  return [
    {
      type: 'OrderCreated',
      orderId: cmd.orderId,
      customerId: cmd.customerId,
      occurredAt: new Date(),
    },
  ];
}

Hier wird die Semantik konkret. Der Handler validiert, und wenn die Bitte nicht erfüllbar ist, lehnt er sie ab – der Command scheitert, bevor irgendeine Tatsache entsteht. Erst wenn die Prüfung durchgeht, wird das Event geboren. Kein Event ohne bestandene Validierung; das ist die Ordnung, die wir wollen.

Auf der anderen Seite steht der Query-Handler. Er tut genau eine Sache: lesen. Keine Mutation, kein emittiertes Event, kein Nebeneffekt:

interface OrderReadModel {
  findSummary(orderId: string): OrderSummary;
}

type OrderSummary = { orderId: string; itemCount: number; status: string };

function handleGetOrderSummary(
  q: Extract<Query, { type: 'GetOrderSummary' }>,
  readModel: OrderReadModel,
): OrderSummary {
  return readModel.findSummary(q.orderId); // read only, no mutation, no events
}

Und damit der Kreis sich schließt: Wie kommt das Read Model überhaupt zu seinen Daten? Über eine Projection, die auf Events reagiert. Die Projection nimmt ein OrderCreated entgegen und aktualisiert die Lesesicht – so fließt die Tatsache aus dem Schreibpfad in die Sicht, die später von Queries gelesen wird:

function applyOrderCreated(
  event: Extract<DomainEvent, { type: 'OrderCreated' }>,
  store: Map<string, OrderSummary>,
): void {
  store.set(event.orderId, {
    orderId: event.orderId,
    itemCount: 0,
    status: 'created',
  });
}

Wer diese vier Bausteine nebeneinanderlegt – Command-Typ, Command-Handler, Projection, Query-Handler –, sieht den gesamten Fluss auf einen Blick. Der Command ist der Wunsch, der Handler prüft ihn, das Event ist die Tatsache, die Projection trägt sie ins Read Model, und die Query liest von dort. Jede Nachricht hat ihren Platz und ihre Bedeutung.

Die Fallstricke, die ich immer wieder sehe

So klar die Theorie ist, so hartnäckig sind die Fehler in der Praxis. Vier davon begegnen mir immer wieder.

Der häufigste ist die verdrehte Benennung. Ein OrderCreated, das eigentlich ein Command ist, oder ein CreateOrder, das als Event durch die Gegend fliegt. Sobald das passiert, verliert das Team die Unterscheidung zwischen „Absicht, die scheitern kann" und „Fakt, der bereits eingetreten ist". Der Name lügt, und mit ihm das Modell. Wenn ich in einem Review ein Event im Imperativ finde, ist das für mich das erste Alarmsignal.

Der zweite Fallstrick ist die Query mit Seiteneffekt. Eine vermeintliche Abfrage, die nebenbei schreibt, einen Zähler hochsetzt oder ein Event feuert. Das bricht die Seiteneffektfreiheit, die Meyer für Queries fordert. Eine Query muss harmlos wiederholbar sein – wenn ich sie zweimal aufrufe, darf sich am System nichts ändern. Sobald eine Abfrage Zustand mutiert, ist sie keine Query mehr, sondern ein verkleideter Command.

Der dritte Fehler ist begrifflicher Natur: CQRS mit Event Sourcing verwechseln. Die Trennung von Command, Event und Query braucht kein Event Sourcing. Sie braucht auch nicht zwingend zwei Datenbanken oder einen asynchronen Message Bus. Man kann die drei Nachrichtenarten in einem ganz gewöhnlichen Service mit einer einzigen Datenbank sauber trennen. Greg Young bringt das Verhältnis auf einen Merksatz: Man kann CQRS ohne Event Sourcing nutzen; nutzt man Event Sourcing, braucht man praktisch CQRS. Nicht umkehren.

Der vierte Fallstrick ist der anämische Command als Setter. Ein UpdateOrder(status = 'shipped') verrät nichts über die fachliche Absicht. Warum wird der Status geändert? Weil das Produkt versandt wurde. Dann heißt der Command ShipProduct, nicht UpdateOrder. Ein Command soll eine fachliche Absicht ausdrücken, keine generische Datenmanipulation. Der Unterschied zwischen UpdateOrder und ShipProduct ist der Unterschied zwischen einem Formular und einer Geschichte.

Ein kurzer Blick über den Tellerrand: CQS, CQRS und die Kosten

Damit die Begriffe nicht durcheinandergeraten, lohnt eine saubere Abgrenzung. CQS – Command-Query Separation nach Meyer – ist ein Prinzip auf Methodenebene: Jede Methode ist entweder Command oder Query. CQRS – von Greg Young geprägt und von Fowler popularisiert – hebt dasselbe Prinzip auf die Objekt- beziehungsweise Serviceebene: ein getrenntes Modell zum Schreiben und ein getrenntes Modell zum Lesen. Fowler formuliert es so, dass man ein anderes Modell zum Aktualisieren von Informationen nutzen kann als das Modell zum Lesen.

Und hier ein ehrliches Wort, das ich in Workshops nie unterschlage: CQRS ist kein Default. Fowler warnt ausdrücklich, dass CQRS riskante Komplexität hinzufügt und nur bei klarem Nutzen gerechtfertigt ist. Getrennte Modelle, eventuell getrennte Speicher, asynchrone Konsistenz – das will man nicht überall. Wer CQRS reflexartig überall einführt, handelt sich mehr Probleme ein, als er löst. Ich habe an anderer Stelle ausführlicher beschrieben, warum CQRS eine fachliche Grenze ist, kein Framework – die Entscheidung für getrennte Modelle gehört an die fachliche Konsistenzgrenze, nicht in jeden Service.

Die gute Nachricht ist: Die reine semantische Trennung von Command, Event und Query kostet fast nichts und lohnt sich fast immer. Sie ist nicht CQRS. Sie ist die begriffliche Hygiene, auf der CQRS später aufsetzen kann, falls man es überhaupt braucht. Man kann Commands imperativ benennen, Events in der Vergangenheit, Queries seiteneffektfrei halten – und dabei in einem völlig konventionellen Service bleiben. Diese Klarheit im Kleinen ist der eigentliche Gewinn.

Roter Faden

Wenn ich das Whiteboard von vorhin noch einmal betrachte, ist die Frage von damals leicht beantwortet. Möchte jemand etwas, oder ist etwas passiert? Ein Command ist der Wunsch, der geprüft wird und scheitern kann. Ein Event ist die Tatsache, die daraus folgt, unveränderlich und nicht verhandelbar. Eine Query ist die Frage, die liest, ohne zu berühren. Drei Nachrichten, drei Bedeutungen, drei Namen – und der Name trägt die Bedeutung nach außen.

Diese Trennung ist kein großes Architekturprojekt. Sie beginnt mit Disziplin bei der Benennung und mit der Weigerung, eine Abfrage heimlich schreiben zu lassen. Man muss weder Event Sourcing einführen noch zwei Datenbanken aufsetzen, um den Nutzen einzufahren. Wer im nächsten Modell konsequent zwischen CreateOrder, OrderCreated und GetOrderSummary unterscheidet, hat schon den größten Teil der Arbeit getan. Alles Weitere – CQRS, Projections, getrennte Speicher – ist eine Frage, die man später und nur bei echtem Bedarf stellt. Die semantische Trennung selbst kostet nichts außer Aufmerksamkeit, und genau die zahlt sich in jedem Modell aus.

Weiterführende Quellen

  • Martin Fowler, CQRS – die kompakte Definition samt Warnung vor der Komplexität.
  • Martin Fowler, CommandQuerySeparation – Meyers ursprüngliches Prinzip auf Methodenebene.
  • Martin Fowler, What do you mean by „Event-Driven"? – zur Einordnung von Events als Nachrichten und Tatsachen.
  • Greg Young, CQRS – der Ursprung der Objekt-/Serviceebene und das Verhältnis zu Event Sourcing.
  • Vaughn Vernon, Implementing Domain-Driven Design (2013), Kapitel zu Domain Events – zur Benennung und Bedeutung von Events in der Domäne.
  • Mein eigenes Vortragsmaterial „CQRS + Event Sourcing" (SlideShare) – die Folien, auf denen ich diese Trennung in Workshops entwickle.

Kommentare