Command und Query sauber trennen
Ein einziges Modell, das sowohl komplexe Geschäftsregeln beim Schreiben durchsetzt als auch performante, denormalisierte Sichten für die Oberfläche liefert, zwingt fast immer zu faulen Kompromissen auf beiden Seiten. CQRS löst diesen Zielkonflikt auf, indem es Befehle, die Zustand ändern, konsequent von Abfragen trennt, die nur lesen, jeweils mit einem eigenen, auf seinen Zweck zugeschnittenen Modell.
Inhaltsverzeichnis
- 1. Warum ein einziges Modell für Lesen und Schreiben oft nicht passt
- 2. Command Query Separation als Ursprungsprinzip
- 3. Eine minimale CommandBus-Implementierung
- 4. Eine minimale QueryBus-Implementierung
- 5. Getrennte Lese- und Schreibmodelle in der Praxis
- 6. Abgrenzung zu und Kombination mit Event Sourcing
- 7. Asynchrone Aktualisierung des Lesemodells und Eventual Consistency
- 8. Wann CQRS schlicht Overkill ist
- 9. Häufige Fehler bei der CQRS-Einführung
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein einziges Modell für Lesen und Schreiben oft nicht passt
Eine Bestellung mit komplexer Fachlogik muss beim Schreiben zahlreiche Invarianten durchsetzen: Lagerbestand prüfen, Rabattregeln anwenden, Zahlungsstatus konsistent halten. Dieselbe Bestellung soll gleichzeitig in der Oberfläche als übersichtliche Liste mit Kundennamen, Summen, Status und diversen Filtermöglichkeiten über mehrere Tabellen hinweg dargestellt werden, was ganz andere Anforderungen an die Datenform stellt.
Ein einzelnes Entity oder Repository, das beide Aufgaben gleichzeitig bedienen soll, wird entweder zum überladenen Gottobjekt, das Lese- und Schreiblogik unsauber vermischt, oder die Oberflächen-Abfragen laufen ineffizient über das domänenorientierte Schreibmodell, mit N-plus-eins-Problemen und unnötig komplexen Joins, nur um simple Anzeigedaten zu bekommen. Mit wachsender fachlicher Komplexität verschärft sich dieses Spannungsfeld weiter, weil jede neue Geschäftsregel auf der Schreibseite das Risiko erhöht, versehentlich auch die Leseseite desselben Modells zu beeinflussen, obwohl beide Seiten fachlich eigentlich nichts miteinander zu tun haben.
2. Command Query Separation als Ursprungsprinzip
CQRS geht auf das von Bertrand Meyer geprägte CQS-Prinzip zurück, das auf Methodenebene gilt: Eine Methode ändert entweder Zustand oder gibt einen Wert zurück, niemals beides gleichzeitig. CQRS überträgt dieses Prinzip von der einzelnen Methode auf die Architekturebene eines gesamten Bounded Context.
Commands wie PlaceOrderCommand beschreiben eine Absicht, Zustand zu ändern, und geben selbst keine fachlichen Daten zurück, höchstens eine erzeugte ID oder eine Erfolgsbestätigung. Queries wie OpenOrdersForCustomerQuery lesen dagegen ausschließlich, ganz ohne jeden Seiteneffekt, und liefern beliebig geformte Daten zurück, exakt so, wie die aufrufende Stelle sie tatsächlich benötigt. Diese klare Trennung erleichtert zusätzlich das Testen, denn ein Command lässt sich über den erzeugten Zustand oder das ausgelöste Event prüfen, während eine Query allein über ihren Rückgabewert bei einem bekannten Datenbestand verifiziert wird, ganz ohne dass beide Testarten sich gegenseitig beeinflussen.
3. Eine minimale CommandBus-Implementierung
Ein CommandBus routet ein Command-Objekt anhand seines Klassennamens an den dafür registrierten Handler, ohne dass der aufrufende Code die konkrete Handler-Klasse selbst kennen muss. Das entkoppelt die Stelle, die eine Absicht äußert, von der Stelle, die sie tatsächlich umsetzt.
Der zugehörige Handler kapselt die eigentliche Schreiblogik: Er lädt gegebenenfalls ein Aggregat, ruft eine passende Befehlsmethode darauf auf und speichert das Ergebnis über ein Repository, ohne selbst irgendwelche Rückgabedaten für die Oberfläche aufzubereiten.
<?php
declare(strict_types=1);
namespace App\Cqrs;
interface Command
{
}
interface CommandHandler
{
public function __invoke(Command $command): void;
}
final class CommandBus
{
/** @var array<class-string, CommandHandler> */
private array $handlers = [];
public function register(string $commandClass, CommandHandler $handler): void
{
$this->handlers[$commandClass] = $handler;
}
public function dispatch(Command $command): void
{
$handler = $this->handlers[$command::class]
?? throw new \RuntimeException('No handler registered for ' . $command::class);
$handler($command);
}
}
final readonly class PlaceOrderCommand implements Command
{
public function __construct(
public string $customerId,
public array $items,
) {
}
}
final class PlaceOrderHandler implements CommandHandler
{
public function __construct(private readonly OrderRepository $orders)
{
}
public function __invoke(Command $command): void
{
/** @var PlaceOrderCommand $command */
$order = Order::place($command->customerId, $command->items);
$this->orders->save($order);
}
}
4. Eine minimale QueryBus-Implementierung
Ein QueryBus funktioniert strukturell fast identisch zum CommandBus, mit dem entscheidenden Unterschied, dass jeder Handler einen Rückgabewert liefert. Die Methode heißt deshalb bewusst nicht dispatch(), sondern ask(), um den semantischen Unterschied zwischen einer Zustandsänderung und einer reinen Abfrage auch im Code sichtbar zu machen.
Query-Handler greifen dabei bewusst direkt auf eine für Lesezugriffe optimierte Tabelle oder Datenbank-View zu, statt über das Domain-Aggregat und dessen Geschäftsregeln zu gehen, da diese Regeln beim reinen Lesen ohnehin keine Rolle spielen.
<?php
declare(strict_types=1);
namespace App\Cqrs;
interface Query
{
}
interface QueryHandler
{
public function __invoke(Query $query): mixed;
}
final class QueryBus
{
/** @var array<class-string, QueryHandler> */
private array $handlers = [];
public function register(string $queryClass, QueryHandler $handler): void
{
$this->handlers[$queryClass] = $handler;
}
public function ask(Query $query): mixed
{
$handler = $this->handlers[$query::class]
?? throw new \RuntimeException('No handler registered for ' . $query::class);
return $handler($query);
}
}
final readonly class OpenOrdersForCustomerQuery implements Query
{
public function __construct(public string $customerId)
{
}
}
final class OpenOrdersForCustomerHandler implements QueryHandler
{
public function __construct(private readonly \PDO $readConnection)
{
}
public function __invoke(Query $query): array
{
/** @var OpenOrdersForCustomerQuery $query */
// Reads directly from a read-optimized view, completely bypassing
// the Order aggregate and its business rules.
$stmt = $this->readConnection->prepare(
'SELECT order_id, status, total_cents, placed_at FROM order_list_view
WHERE customer_id = :customer_id AND status != :status'
);
$stmt->execute(['customer_id' => $query->customerId, 'status' => 'closed']);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
5. Getrennte Lese- und Schreibmodelle in der Praxis
Auf der Schreibseite steht ein vollwertiges Order-Aggregat mit sämtlichen Geschäftsregeln, das über ein Repository geladen wird. Ein Command-Handler ruft eine passende Methode auf diesem Aggregat auf und speichert es anschließend unverändert über dasselbe Repository zurück. Dieses Aggregat kennt weder die konkrete Darstellung in der Oberfläche noch irgendwelche Filter- oder Sortierwünsche, seine einzige Aufgabe ist es, die fachlichen Regeln beim Schreiben korrekt und vollständig durchzusetzen.
Auf der Leseseite steht dagegen ein schlankes OrderListItemDto, das direkt per SQL aus einer für Lesezwecke optimierten Tabelle oder View befüllt wird, ganz ohne den Umweg über das Domain-Aggregat, und exakt mit den Feldern ausgestattet ist, die die Oberfläche tatsächlich braucht, nicht mehr und nicht weniger.
6. Abgrenzung zu und Kombination mit Event Sourcing
CQRS ist unabhängig von Event Sourcing und funktioniert genauso gut mit klassischer relationaler Persistenz auf der Schreibseite: Ein Command-Handler kann ganz normal per UPDATE in derselben Tabelle schreiben, aus der später, oder sogar direkt, gelesen wird.
Eine Kombination beider Patterns ist trotzdem naheliegend: Event Sourcing liefert dann das Schreibmodell in Form eines aus Events rekonstruierten Aggregats, während aus demselben Event-Strom asynchron beliebig viele Lesemodelle als Projektionen aufgebaut werden, wie im separaten Artikel zu Event Sourcing beschrieben. CQRS ist dabei die architektonische Trennung, Event Sourcing eine mögliche, aber keineswegs zwingende Implementierungstechnik für die Schreibseite.
7. Asynchrone Aktualisierung des Lesemodells und Eventual Consistency
Sobald Lese- und Schreibmodell getrennt sind und das Lesemodell asynchron aktualisiert wird, etwa über eine Nachricht, die nach einem erfolgreichen Command verschickt wird, entsteht zwangsläufig eine kurze Verzögerung. Ein Nutzer, der gerade eine Bestellung aufgegeben hat, könnte auf der Leseseite für einen kurzen Moment noch den alten Zustand sehen, bis die Projektion nachgezogen wurde.
Übliche Strategien dafür sind ein Read-your-writes-Muster, bei dem direkt nach einem eigenen Command kurzzeitig vom Schreibmodell statt vom Lesemodell gelesen wird, ein Hinweis in der Oberfläche wie 'wird aktualisiert', oder schlicht die bewusste Akzeptanz einer kurzen Verzögerung, wo das fachlich unproblematisch ist.
8. Wann CQRS schlicht Overkill ist
Für einfache, CRUD-lastige Anwendungen ohne stark abweichende Lese- und Schreibanforderungen und ohne echte fachliche Komplexität fügt die Einführung von CommandBus und QueryBus vor allem zusätzliche Zeremonie hinzu, ohne einen spürbaren Nutzen zu liefern. Ein direkter Aufruf eines Repositorys ist dann schneller gebaut und für neue Teammitglieder deutlich leichter zu verstehen.
Ein pragmatischer Mittelweg ist, CQRS gezielt nur in den wenigen tatsächlich fachlich komplexen Bounded Contexts einer Anwendung einzusetzen, während einfache CRUD-Bereiche derselben Anwendung weiterhin mit einem einzigen, direkten Modell auskommen. So bleibt der Mehraufwand auf die Stellen begrenzt, an denen er sich fachlich auch wirklich auszahlt. Ein guter Indikator für diese Entscheidung ist, ob die Leseanforderungen der Oberfläche bereits heute spürbar von der Struktur des Schreibmodells abweichen, oder ob eine einfache, direkte Abfrage über das bestehende Modell weiterhin völlig ausreicht.
9. Häufige Fehler bei der CQRS-Einführung
Ein verbreiteter Fehler ist, CQRS aus reinem Dogma überall in einer Anwendung einzuführen, statt es gezielt dort einzusetzen, wo eine echte fachliche Notwendigkeit besteht. Ebenso problematisch sind Query-Handler, die versehentlich Zustand verändern, etwa durch ein beiläufiges Logging mit Seiteneffekt oder eine Zähler-Erhöhung bei jedem Lesevorgang, was dem Grundprinzip klar widerspricht.
Ein weiterer häufiger Fehler ist die Annahme, CQRS erfordere automatisch Event Sourcing oder eine physisch separate Datenbank, was beides nicht stimmt. Und schließlich wird oft die Komplexität durch Eventual Consistency unterschätzt, sobald das Lesemodell tatsächlich asynchron aktualisiert wird, was ohne bewusste Strategie zu verwirrenden, scheinbar inkonsistenten Zuständen in der Oberfläche führen kann.
| Kriterium | Einzelnes Modell (CRUD) | CQRS |
|---|---|---|
| Fachliche Komplexität beim Schreiben | Gering bis mittel | Hoch, viele Invarianten |
| Leseanforderungen | Ähnlich dem Schreibmodell | Stark abweichend, aggregiert, denormalisiert |
| Einstiegshürde für das Team | Niedrig | Höher, zwei Modelle plus Infrastruktur |
| Konsistenzanforderung | Sofortige Konsistenz erwartet | Eventual Consistency oft akzeptabel |
| Typischer Einsatzbereich | Admin-Backends, einfache CRUD-Module | Bestellabwicklung, Buchhaltung, komplexe Workflows |
Mironsoft
PHP-Modernisierung, Code-Qualität und Legacy-Refactoring
Gewachsener PHP-Code, der niemand mehr gern anfasst?
Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.
Legacy-Refactoring
Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.
Code-Qualität etablieren
PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.
Versions-Upgrade
PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.
10. Zusammenfassung
CQRS in PHP: Das Wichtigste auf einen Blick
Grundprinzip
Commands ändern Zustand ohne Rückgabewert, Queries lesen ohne Seiteneffekt, jeweils mit eigenem Modell.
Infrastruktur
Ein CommandBus und ein QueryBus routen Anfragen anhand ihres Typs an passende Handler.
Abgrenzung
CQRS ist unabhängig von Event Sourcing, beide Patterns lassen sich aber sinnvoll kombinieren.
Einsatzentscheidung
Nur bei echter fachlicher Komplexität oder stark abweichenden Leseanforderungen einsetzen.