getrennte Command- und Query-Bus-Konfiguration mit Messenger
CQRS trennt lesende von schreibenden Operationen, aber diese Trennung bleibt halbherzig, solange beide über denselben Messenger-Bus mit identischer Middleware laufen. Zwei separate Busse, command.bus und query.bus, mit jeweils eigenem Middleware-Stack machen die Trennung nicht nur konzeptionell, sondern auch technisch konsequent und verhindern, dass Query-Handler unnötig in Transaktionen laufen oder Command-Handler ohne Validierung durchrutschen.
Inhaltsverzeichnis
- 1. Warum ein gemeinsamer Bus für Commands und Queries nicht reicht
- 2. Zwei Busse in der messenger.yaml konfigurieren
- 3. Unterschiedliche Middleware-Stacks: Validierung vs. kein Transaction-Wrapping
- 4. Handler-Registrierung für Commands und Queries getrennt halten
- 5. Ein Command-Beispiel mit Validierung und Transaktion
- 6. Ein Query-Beispiel ohne Transaktions-Overhead
- 7. Fehlerbehandlung getrennt pro Bus gestalten
- 8. Synchrone Queries, teilweise asynchrone Commands
- 9. Testbarkeit durch die klare Bus-Trennung
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein gemeinsamer Bus für Commands und Queries nicht reicht
Der Symfony-Messenger-Component bringt standardmäßig einen einzigen Bus mit, der sowohl Commands als auch Queries verteilen kann. Solange nur wenige Handler existieren, funktioniert das auch, doch sobald Middleware ins Spiel kommt, zeigt sich der Nachteil dieser Vereinfachung. Ein Transaction-Middleware, das jede Nachricht in eine Datenbank-Transaktion einwickelt, ist für einen schreibenden Command sinnvoll, verursacht bei einer rein lesenden Query aber unnötigen Overhead und sperrt im schlimmsten Fall Datenbank-Ressourcen ohne jeden Nutzen.
Umgekehrt gilt das Gleiche für Validierung: Ein Command, der einen neuen Kunden anlegt, sollte niemals ohne vorherige Validierung der Eingabedaten ausgeführt werden, während eine Query, die lediglich Kundendaten liest, keine Eingabevalidierung im klassischen Sinn braucht, sondern höchstens Parameter-Constraints. Wenn beide Nachrichtentypen denselben Middleware-Stack durchlaufen, muss entweder jede Middleware selbst zwischen Command und Query unterscheiden, was den Code unnötig verkompliziert, oder man nimmt ungenutzte beziehungsweise fehlplatzierte Middleware-Aufrufe in Kauf.
2. Zwei Busse in der messenger.yaml konfigurieren
Die Konfiguration von zwei getrennten Bussen erfolgt zentral in der messenger.yaml unter dem buses-Schlüssel. Jeder Bus bekommt einen eigenen Namen, hier command.bus und query.bus, sowie eine eigene Liste von Middleware, die nur für Nachrichten dieses Busses ausgeführt wird. Symfony registriert dabei automatisch je einen Service mit dem entsprechenden Namen im Container, sodass Handler und aufrufender Code explizit angeben, welchen Bus sie verwenden wollen, statt sich auf einen impliziten Standard-Bus zu verlassen.
Wichtig ist, dass Commands und Queries auch als eigene Nachrichtentypen modelliert werden, etwa durch ein gemeinsames CommandInterface beziehungsweise QueryInterface, das reine Marker-Interfaces ohne Methoden sein kann. Über routing lassen sich Nachrichten dann anhand ihrer Klasse oder ihres Interfaces automatisch dem passenden Bus zuordnen, sodass ein Aufrufer nicht bei jedem dispatch()-Aufruf manuell entscheiden muss, welcher Bus gemeint ist.
# config/packages/messenger.yaml
framework:
messenger:
buses:
command.bus:
middleware:
- validation
- App\Messenger\Middleware\TransactionMiddleware
query.bus:
middleware:
- App\Messenger\Middleware\QueryLoggingMiddleware
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Command\CommandInterface': command.bus
'App\Query\QueryInterface': query.bus
3. Unterschiedliche Middleware-Stacks: Validierung vs. kein Transaction-Wrapping
Für den command.bus ist eine Kombination aus validation-Middleware, die eingehende Commands über Symfonys Validator-Component prüft, und einer eigenen TransactionMiddleware sinnvoll, die den Handler-Aufruf in eine Doctrine-Transaktion einwickelt und bei einer Exception automatisch zurückrollt. Das stellt sicher, dass ein Command entweder vollständig erfolgreich ist oder gar keine Änderung hinterlässt, was für schreibende Operationen ein zentrales Konsistenz-Versprechen ist.
Der query.bus verzichtet bewusst auf beide Middleware-Komponenten. Eine Query liest nur Daten und verändert per Definition keinen Zustand, weshalb ein Transaction-Wrapping keinen Mehrwert bietet und lediglich zusätzliche Datenbank-Locks anfordert, die bei parallelen Lesezugriffen zu unnötigen Wartezeiten führen können. Stattdessen kann der query.bus mit einer schlanken Logging- oder Caching-Middleware ausgestattet werden, die etwa häufig wiederholte Queries für eine kurze Zeit zwischenspeichert.
4. Handler-Registrierung für Commands und Queries getrennt halten
Symfony erkennt Message-Handler standardmäßig automatisch anhand ihres Type-Hints in der __invoke-Methode oder über das MessageHandlerInterface, unabhängig davon, an welchem Bus sie hängen sollen. Damit ein Handler eindeutig einem Bus zugeordnet wird, reicht in der Regel bereits das Routing über das jeweilige Marker-Interface aus, doch bei komplexeren Setups mit mehreren Bussen für denselben Nachrichtentyp lässt sich die Zuordnung zusätzlich explizit über das AsMessageHandler-Attribut mit dem bus-Parameter festlegen.
In der Praxis empfiehlt sich eine konsequente Namenskonvention: CommandHandler-Klassen liegen im Namespace App\Command\Handler, QueryHandler-Klassen im Namespace App\Query\Handler. Diese klare Trennung im Verzeichnisbaum macht auf einen Blick sichtbar, welche Klasse für welchen Bus gedacht ist, und verhindert, dass ein Command-Handler versehentlich für eine Query registriert wird oder umgekehrt.
5. Ein Command-Beispiel mit Validierung und Transaktion
Ein CreateOrderCommand trägt alle für die Bestellerstellung nötigen Daten als unveränderliche Properties, idealerweise mit Symfony-Validator-Constraints direkt an den Properties annotiert. Beim Dispatch über den command.bus prüft die validation-Middleware diese Constraints, bevor der eigentliche Handler überhaupt aufgerufen wird, sodass ungültige Commands gar nicht erst in die Business-Logik gelangen.
Der zugehörige CreateOrderHandler enthält die eigentliche Logik zum Anlegen der Bestellung und wird komplett von der TransactionMiddleware umschlossen. Schlägt irgendein Schritt fehl, etwa weil ein referenziertes Produkt nicht mehr existiert, wird die gesamte Transaktion zurückgerollt, und es bleibt kein inkonsistenter Zwischenzustand in der Datenbank zurück.
6. Ein Query-Beispiel ohne Transaktions-Overhead
Eine GetOrderDetailsQuery trägt lediglich die zur Identifikation nötigen Parameter, etwa eine Bestell-ID, und wird über den query.bus verteilt. Der zugehörige Handler liest die Daten direkt über ein Read-Model oder Repository und gibt ein DTO zurück, ohne dass irgendeine Transaktion oder Validierungs-Middleware dazwischengeschaltet ist. Das macht den Lesepfad messbar schneller, weil jede eingesparte Middleware auch eingesparte Ausführungszeit bedeutet.
Besonders bei Read-Modellen, die für die Anzeige optimiert und nicht zwingend deckungsgleich mit dem Schreib-Modell sind, zeigt sich der Vorteil der Trennung deutlich: Eine Query kann direkt gegen eine denormalisierte View oder eine eigene Projektion lesen, während Commands weiterhin gegen das normalisierte Schreib-Modell mit vollem Transaktions-Schutz arbeiten.
7. Fehlerbehandlung getrennt pro Bus gestalten
Da Messenger jeden Handler-Aufruf in einer HandlerFailedException kapselt, lohnt sich pro Bus ein eigener ExceptionListener oder eine eigene Middleware, die diese Exception wieder auf die ursprüngliche entpackt und angemessen behandelt. Für den command.bus bedeutet das meist, Validierungsfehler in eine strukturierte HTTP-400-Antwort umzuwandeln, während für den query.bus eher ein einfaches Not-Found-Handling gefragt ist, wenn die angefragten Daten nicht existieren.
Diese getrennte Fehlerbehandlung passt zur unterschiedlichen Semantik beider Bus-Typen: Ein fehlgeschlagener Command signalisiert meist ein Problem mit der Eingabe oder dem aktuellen Systemzustand, während eine fehlgeschlagene Query oft einfach bedeutet, dass die angefragte Ressource nicht gefunden wurde, was kein Fehler im eigentlichen Sinn, sondern ein regulärer, erwartbarer Fall ist.
8. Synchrone Queries, teilweise asynchrone Commands
Queries werden in aller Regel synchron ausgeführt, weil ein Nutzer typischerweise sofort ein Ergebnis erwartet und asynchrone Verarbeitung bei reinen Lesevorgängen selten einen Vorteil bringt. Für den query.bus wird deshalb meist kein Transport konfiguriert, sodass Nachrichten immer synchron im gleichen Prozess verarbeitet werden.
Beim command.bus kann es dagegen sinnvoll sein, bestimmte Commands über einen asynchronen Transport laufen zu lassen, etwa einen SendWelcomeEmailCommand, der nicht Teil der eigentlichen Transaktion sein muss und die Antwortzeit des Hauptrequests nicht verlängern soll. Über das routing-Mapping in der messenger.yaml lassen sich einzelne Command-Klassen gezielt einem asynchronen Transport wie async zuordnen, während andere weiterhin synchron und transaktional bleiben.
9. Testbarkeit durch die klare Bus-Trennung
Die Trennung in zwei Busse erleichtert auch das Testen erheblich, weil sich in Funktionstests gezielt nur der command.bus oder nur der query.bus mit einem In-Memory-Transport austauschen lässt, um zu prüfen, ob ein bestimmtes Command tatsächlich dispatched wurde, ohne dass der andere Bus davon berührt wird. Handler-Tests wiederum profitieren davon, dass Command-Handler und Query-Handler durch die Namenskonvention und Verzeichnisstruktur klar getrennt sind und sich unabhängig voneinander mit gezielten Unit-Tests abdecken lassen.
In der Praxis zeigt sich, dass Teams, die konsequent zwischen beiden Bussen trennen, seltener versehentlich Business-Logik in Query-Handlern unterbringen, weil das Fehlen der Transaction-Middleware und der fehlende Schreibzugriff im Read-Model dieses Muster von vornherein erschweren. Die Architektur erzwingt damit auf technischer Ebene eine Disziplin, die sich rein durch Konvention oder Code-Review allein oft nur schwer durchhalten lässt.
| Aspekt | command.bus | query.bus |
|---|---|---|
| Zweck | Zustand verändern | Zustand lesen |
| Validation-Middleware | aktiv | nicht nötig |
| Transaction-Middleware | aktiv, mit Rollback | nicht eingebunden |
| Ausführung | synchron oder asynchron | in der Regel synchron |
| Typischer Rückgabewert | void oder ID | DTO / Read-Model |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
CQRS mit getrennten Bussen: Das Wichtigste auf einen Blick
Trennung
zwei eigenständige Messenger-Busse statt eines gemeinsamen Busses
Command-Middleware
Validierung plus Transaktion mit automatischem Rollback
Query-Middleware
schlank, ohne Transaction-Wrapping, optional mit Caching
Vorteil
passende Middleware pro Nachrichtentyp statt Einheits-Stack