CQRS in PHP praktisch umgesetzt: Command und Query trennen
AI generated
8.4
PHP, Resilienz, Architektur-Patterns
CQRS in PHP praktisch umgesetzt
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.

15 Min. Lesezeit CommandBus, QueryBus Lese- und Schreibmodell

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.

11. FAQ: CQRS in PHP: Das Wichtigste auf einen Blick

1Wofür steht CQRS?
Command Query Responsibility Segregation, also die bewusste Trennung von Befehlen, die Zustand ändern, und Abfragen, die nur lesen, in jeweils eigene Modelle statt einem gemeinsamen Modell für beides.
2Was ist der Unterschied zwischen CQS und CQRS?
Command Query Separation ist ein Prinzip auf Methodenebene aus der objektorientierten Programmierung. CQRS überträgt diese Idee auf die Architekturebene und trennt ganze Modelle statt nur einzelner Methoden.
3Muss ich für CQRS zwei separate Datenbanken betreiben?
Nein. CQRS beschreibt zunächst nur die logische Trennung von Lese- und Schreibmodell im Code. Beide können, gerade zu Beginn, problemlos auf derselben Datenbank aufsetzen.
4Ist CQRS dasselbe wie Event Sourcing?
Nein, es sind zwei unabhängige Patterns. CQRS funktioniert auch mit klassischer relationaler Persistenz, Event Sourcing lässt sich aber gut als Schreibmodell mit CQRS kombinieren.
5Was macht ein CommandBus konkret?
Er nimmt ein Command-Objekt entgegen, ermittelt anhand seines Typs den zuständigen Handler und ruft diesen auf, ohne dass der aufrufende Code die konkrete Handler-Klasse kennen muss.
6Warum sollte ein Query-Handler niemals Zustand ändern?
Weil das dem Grundprinzip von CQRS widerspricht und dazu führt, dass ein scheinbar harmloser Lesevorgang unerwartete Seiteneffekte auslöst, was Nachvollziehbarkeit und Testbarkeit deutlich erschwert.
7Was bedeutet Eventual Consistency im CQRS-Kontext?
Wenn das Lesemodell asynchron aus dem Schreibmodell aktualisiert wird, kann eine gerade erst geschriebene Änderung im Lesemodell für einen kurzen Moment noch nicht sichtbar sein, bis die Aktualisierung nachgezogen wurde.
8Wann ist CQRS die falsche Wahl?
Bei einfachen, CRUD-lastigen Bereichen ohne echte fachliche Komplexität und ohne stark abweichende Leseanforderungen, wo ein einzelnes, direktes Modell schneller zu bauen und leichter zu verstehen ist.
9Kann ich CQRS nur für einen Teil einer Anwendung einsetzen?
Ja, und das ist sogar empfehlenswert. CQRS lässt sich gezielt auf einzelne, fachlich komplexe Bounded Contexts anwenden, während andere, einfachere Bereiche derselben Anwendung ein einzelnes Modell behalten.
10Wie teste ich Command- und Query-Handler?
Command-Handler werden über das erzeugte Domain Event oder den veränderten Aggregat-Zustand geprüft, Query-Handler über die zurückgegebenen Daten bei einem bekannten, vorbereiteten Datenbestand, jeweils unabhängig voneinander.