Symfony CQRS: getrennte Command- und Query-Bus-Konfiguration mit Messenger
AI generated
SF
{ }
Symfony · CQRS · Messenger · Architektur
Symfony CQRS
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.

16 Min. Lesezeit Symfony Messenger CQRS · command.bus · query.bus

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

11. FAQ: CQRS mit getrennten Bussen: Das Wichtigste auf einen Blick

1Warum reicht ein einziger Messenger-Bus für CQRS nicht aus?
Weil Commands und Queries unterschiedliche Anforderungen an Middleware haben. Ein gemeinsamer Bus zwingt entweder beide Nachrichtentypen durch dieselbe Middleware oder erfordert Sonderfälle innerhalb jeder Middleware.
2Wie werden zwei Busse in Symfony konfiguriert?
Über den buses-Schlüssel in der messenger.yaml, wo jeder Bus einen eigenen Namen und eine eigene Middleware-Liste bekommt. Üblich sind die Namen command.bus und query.bus.
3Braucht der Query-Bus wirklich keine Transaction-Middleware?
In den meisten Fällen nicht, da Queries per Definition keinen Zustand verändern. Ein Transaction-Wrapping würde nur unnötige Datenbank-Locks erzeugen, ohne einen Konsistenzvorteil zu bieten.
4Wie wird ein Command automatisch dem richtigen Bus zugeordnet?
Über das routing-Mapping in der messenger.yaml, das Nachrichtenklassen oder Marker-Interfaces wie CommandInterface auf einen bestimmten Bus abbildet, sodass der Dispatch-Code selbst nicht zwischen Bussen unterscheiden muss.
5Können Commands asynchron laufen, Queries aber synchron bleiben?
Ja. Für den command.bus lassen sich einzelne Commands über das routing einem asynchronen Transport zuordnen, während der query.bus in der Regel ganz ohne Transport, also synchron, konfiguriert bleibt.
6Was passiert bei einem Fehler in einem Command-Handler?
Die TransactionMiddleware fängt die Exception ab, rollt die Datenbank-Transaktion zurück und die ursprüngliche Exception wird aus der HandlerFailedException entpackt und angemessen weiterverarbeitet.
7Wie wird Validierung für Commands eingebunden?
Über die eingebaute validation-Middleware von Symfony Messenger, die auf dem command.bus registriert wird und Commands anhand ihrer Validator-Constraints prüft, bevor der Handler aufgerufen wird.
8Ist die Trennung in zwei Busse auch bei kleinen Projekten sinnvoll?
Bei sehr kleinen Projekten mit wenigen Handlern ist der Aufwand oft nicht gerechtfertigt. Sobald jedoch mehrere Middleware-Komponenten mit unterschiedlichen Anforderungen für Commands und Queries entstehen, zahlt sich die Trennung schnell aus.
9Lassen sich Query-Handler cachen?
Ja, über eine eigene Caching-Middleware auf dem query.bus, die Ergebnisse häufig wiederholter Queries für eine kurze Zeit zwischenspeichert, ohne die Command-Seite zu beeinflussen.
10Erschwert die Bus-Trennung das Testen?
Im Gegenteil, sie erleichtert es. In Tests kann gezielt nur ein Bus mit einem In-Memory-Transport ausgetauscht werden, um isoliert zu prüfen, ob ein bestimmtes Command oder eine bestimmte Query dispatched wurde.