Commands und Queries sauber trennen
Controller, die gleichzeitig Daten schreiben, lesen, transformieren und zurückgeben — das ist der Normalzustand in vielen Symfony-Projekten. CQRS trennt diese Verantwortlichkeiten konsequent: Commands ändern Zustand, Queries lesen Daten. Das Resultat ist wartbarer, testbarer und skalierbarer Code.
Inhaltsverzeichnis
- 1. CQRS-Grundlagen: Was ist Command Query Responsibility Segregation?
- 2. Command Bus mit Symfony Messenger aufbauen
- 3. Commands: Typsichere Schreiboperationen
- 4. Query Bus: Immer synchron, immer mit Return-Wert
- 5. Read Models: Optimierte Lesestrukturen
- 6. Handler: Trennung von Command- und Query-Logik
- 7. CQRS im Controller: Thin Controller, Fat Domain
- 8. CQRS-Code testen: Commands und Queries isoliert
- 9. CQRS vs. Traditional Service Layer im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. CQRS-Grundlagen: Was ist Command Query Responsibility Segregation?
CQRS — Command Query Responsibility Segregation — ist ein Architekturmuster, das von Bertrand Meyer's Command-Query-Separation-Prinzip abstammt. Der Kern des Prinzips ist simpel: Eine Funktion soll entweder eine Operation ausführen und den Zustand ändern (Command) oder Daten zurückgeben und dabei den Zustand nicht verändern (Query) — aber nie beides gleichzeitig. In der Praxis bedeutet das: Methoden wie getUserAndMarkAsLastVisited() verstoßen gegen das Prinzip, weil sie lesen und schreiben in einer Operation kombinieren. CQRS zieht diese Trennung auf Architekturebene durch: Zwei separate Modelle, zwei separate Handler-Klassen, zwei separate Datenpfade.
Der Vorteil von CQRS liegt nicht in der Trennung selbst, sondern in den Möglichkeiten, die sie eröffnet: Das Schreibmodell kann für Konsistenz und Domänenkorrektheit optimiert werden — mit Aggregaten, Domain-Events und strenger Validierung. Das Lesemodell kann für Performance optimiert werden — mit denormierten Daten, Read Models, Caching und direkten SQL-Abfragen ohne Entity-Overhead. In einem Symfony-Projekt mit Symfony Messenger als Bus-Infrastruktur entsteht eine saubere CQRS-Implementierung ohne externe Bibliotheken: Commands gehen über den Command Bus, Queries über den Query Bus, beide mit dedizierten Handlern.
2. Command Bus mit Symfony Messenger aufbauen
Symfony Messenger wird als CQRS-Command-Bus konfiguriert, indem man separate Bus-Instanzen für Commands und Queries definiert. Die Konfiguration in config/packages/messenger.yaml definiert zwei Buses: command.bus und query.bus. Der Command Bus hat die Middleware HandleMessageMiddleware mit der Einstellung allow_no_handlers: false — jeder Command muss genau einen Handler haben, sonst ist das ein Konfigurationsfehler. Der Query Bus hat dieselbe Einstellung und gibt den Return-Wert des Handlers zurück, was ein wichtiges Detail bei Symfony Messenger ist: $messageBus->dispatch() gibt ein Envelope-Objekt zurück, aus dem man mit HandledStamp::class das Ergebnis des Handlers auslesen kann.
Die Trennung in zwei Bus-Instanzen erzwingt die CQRS-Regeln auf Service-Level. Ein Controller, der eine Query über den Command Bus dispatcht, erhält einen Konfigurationsfehler, weil Queries keine Command-Handler haben. Symfony's DI-Container macht es möglich, den richtigen Bus über Service-Aliase zu injizieren: MessageBusInterface $commandBus und MessageBusInterface $queryBus werden durch entsprechende Binding-Konfiguration auf die richtigen Bus-Instanzen aufgelöst. Das ermöglicht typsichere Injection ohne manuelle Service-IDs in jedem Controller.
# config/packages/messenger.yaml — CQRS: separate Command Bus and Query Bus
framework:
messenger:
# Command Bus: exactly one handler per command, can be async
buses:
command.bus:
middleware:
- doctrine_transaction # wrap in DB transaction
- validation # validate command before handler
# Query Bus: always sync, always returns a value
query.bus:
default_middleware:
enabled: true
allow_no_handlers: false # query without handler is a bug
# Event Bus: 0..n handlers, async OK
event.bus:
default_middleware:
allow_no_handlers: true
# Only commands can be routed async — queries must be sync
routing:
App\Command\*: async
App\Query\*: ~ # no transport = always synchronous
App\Domain\Event\*: async
# services.yaml — type-safe bus injection via named binding
# services:
# _defaults:
# bind:
# $commandBus: '@command.bus'
# $queryBus: '@query.bus'
3. Commands: Typsichere Schreiboperationen
Ein CQRS-Command ist ein Value Object, das die Absicht einer Zustandsänderung ausdrückt. Der Name eines Commands ist eine Aussage im Imperativ: RegisterUserCommand, PublishArticleCommand, CancelOrderCommand. Commands tragen alle für die Operation notwendigen Daten — keine Datenbankentitäten, keine Services, nur Skalare und Value Objects. Sie sind immutable und werden nach dem Erstellen nicht mehr verändert. Constructor Property Promotion in PHP 8.x macht Commands kompakt: Jede Property ist readonly, der Constructor deklariert alles in einer Zeile.
Commands dürfen keinen Return-Wert zurückgeben. Das ist die CQRS-Regel für Commands: Entweder die Operation gelingt und ein Domain-Event wird gefeuert, oder sie schlägt mit einer Exception fehl. Kein Controller sollte nach dem Dispatchen eines Commands auf ein Ergebnis warten — wenn der Controller nach dem Speichern die generierte ID für eine Redirect-URL braucht, übergibt man die ID bereits im Command (z.B. eine UUID, die der Client oder der Controller vorab generiert), oder man fragt sie per separater Query nach dem Command ab. Diese Konvention klingt restriktiv, erzwingt aber sauberere Architektur: Commands sind feuer-und-vergessen, die Auswirkung ist ein Event.
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Command: register a new user account.
* Immutable value object — all data is set at construction, never mutated.
*/
final readonly class RegisterUserCommand
{
public function __construct(
// Pre-assigned UUID — no need to return generated ID from handler
public readonly Uuid $userId,
#[Assert\NotBlank]
#[Assert\Email]
public readonly string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 72)]
public readonly string $plainPassword,
#[Assert\Choice(['de', 'en', 'fr'])]
public readonly string $locale = 'de',
) {}
}
// ---------------------------------------------------------------------------
// In the controller: generate UUID before dispatch, use it for redirect
namespace App\Controller;
use App\Command\RegisterUserCommand;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Uid\Uuid;
final class RegistrationController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly RouterInterface $router,
) {}
public function register(/* ... FormData ... */): RedirectResponse
{
// Generate ID before dispatch — no need to read it back from handler
$userId = Uuid::v4();
$this->commandBus->dispatch(new RegisterUserCommand(
userId: $userId,
email: 'user@example.com',
plainPassword: 'securepassword',
));
// Redirect immediately — command succeeded (no exception = success)
return new RedirectResponse($this->router->generate('app_user_show', [
'id' => $userId->toRfc4122(),
]));
}
}
4. Query Bus: Immer synchron, immer mit Return-Wert
Queries in CQRS sind das Gegenstück zu Commands: Sie ändern keinen Zustand, geben immer einen Wert zurück und laufen immer synchron. Ein Query ist ebenfalls ein Value Object mit beschreibendem Namen: GetProductByIdQuery, FindProductsByCategoryQuery, GetOrderSummaryQuery. Der Name beschreibt, was abgefragt wird, nicht wie. Die Query trägt alle Filterparameter — IDs, Suchbegriffe, Sortierung, Pagination — als immutable Properties.
Das Auslesen des Return-Werts aus Symfony Messenger ist ein konkretes Detail, das bei der CQRS-Implementierung oft Verwirrung stiftet. $bus->dispatch($query) gibt ein Envelope-Objekt zurück, nicht direkt den Handler-Return-Wert. Man liest den Wert mit $envelope->last(HandledStamp::class)->getResult(). Eine Hilfsmethode oder ein Query-Bus-Wrapper kapselt dieses Detail elegant: $result = $this->ask($query) statt $this->queryBus->dispatch($query)->last(HandledStamp::class)->getResult(). Dieser Wrapper macht Controller lesbarer und Tests einfacher, weil man nur den Wrapper mocken muss, nicht den gesamten Messenger-Stack.
5. Read Models: Optimierte Lesestrukturen
Das größte Performance-Potenzial von CQRS liegt in den Read Models. Statt Doctrine-Entities mit allen ihren Relationen, Lazy-Loading-Proxies und Mapping-Overhead zu laden, erzeugt ein Query-Handler direkt ein flaches DTO (Data Transfer Object) aus einem raw SQL-Statement. Ein ProductListItemReadModel enthält genau die Felder, die eine Produktliste anzeigt: Name, Preis, Thumbnail-URL, verfügbare Menge, Durchschnitts-Bewertung. Keine unnötigen Joins, keine ungeladenen Relationen, kein Serialisierungsaufwand für Felder, die nie angezeigt werden.
In Symfony ist DBAL das Werkzeug der Wahl für Read-Model-Queries: Direkte SQL-Abfragen mit $this->connection->fetchAllAssociative($sql, $params) geben assoziative Arrays zurück, die in Read-Model-Objekte gemappt werden. Das vermeidet den gesamten Doctrine-ORM-Overhead für lesende Operationen. CQRS erlaubt diese Optimierung ohne Verlust von Domänenkonsistenz: Die Schreibseite nutzt weiterhin Doctrine-Entities mit vollständiger Domänenlogik, die Leseseite nutzt optimierte Raw-SQL-Abfragen, die auf die spezifischen View-Anforderungen zugeschnitten sind. Ein Read Model kann sogar aus einem denormierten View oder einem Elasticsearch-Index kommen — der Query-Handler abstrahiert die Datenquelle vollständig.
<?php
declare(strict_types=1);
namespace App\ReadModel;
/**
* Read model for product list — flat DTO optimized for list views.
* Contains exactly what the list template needs, nothing more.
*/
final readonly class ProductListItemReadModel
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $slug,
public readonly string $price,
public readonly string $currency,
public readonly string $thumbnailUrl,
public readonly int $stockQuantity,
public readonly float $averageRating,
public readonly int $reviewCount,
) {}
/** Factory from raw DBAL associative array result */
public static function fromArray(array $row): self
{
return new self(
id: $row['id'],
name: $row['name'],
slug: $row['slug'],
price: $row['price'],
currency: $row['currency'],
thumbnailUrl: $row['thumbnail_url'] ?? '/images/placeholder.svg',
stockQuantity: (int) $row['stock_quantity'],
averageRating: (float) $row['average_rating'],
reviewCount: (int) $row['review_count'],
);
}
}
// ---------------------------------------------------------------------------
// Query Handler using raw DBAL — no Doctrine ORM overhead for reads
namespace App\QueryHandler;
use App\Query\FindProductsByCategoryQuery;
use App\ReadModel\ProductListItemReadModel;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class FindProductsByCategoryHandler
{
public function __construct(
private Connection $connection,
) {}
/** @return ProductListItemReadModel[] */
public function __invoke(FindProductsByCategoryQuery $query): array
{
// Raw SQL — optimized for the list view, no unnecessary joins
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
p.id, p.name, p.slug, p.price, p.currency,
p.thumbnail_url,
COALESCE(p.stock_quantity, 0) AS stock_quantity,
COALESCE(AVG(r.rating), 0) AS average_rating,
COUNT(r.id) AS review_count
FROM product p
LEFT JOIN product_review r ON r.product_id = p.id
WHERE p.category_id = :categoryId
AND p.is_published = 1
GROUP BY p.id
ORDER BY p.sort_order ASC, p.name ASC
LIMIT :limit OFFSET :offset
SQL,
[
'categoryId' => $query->categoryId,
'limit' => $query->limit,
'offset' => ($query->page - 1) * $query->limit,
],
);
return array_map(ProductListItemReadModel::fromArray(...), $rows);
}
}
6. Handler: Trennung von Command- und Query-Logik
Command-Handler in CQRS sind die einzigen Klassen, die Zustandsänderungen vornehmen dürfen. Ein Command-Handler lädt ein Aggregat aus dem Repository, ruft eine Domänenmethode auf, persistiert das Aggregat und dispatcht ein Domain-Event. Er gibt nichts zurück — der Rückgabetyp ist void. Die einzige Kommunikation nach außen ist via Exception (Fehlerfall) oder Domain-Event (Erfolgsfall). Diese Regel erzwingt konsequente Entkopplung: Controller und andere Handler müssen Zustandsänderungen über Events mitbekommen, nicht über Rückgabewerte.
Query-Handler in CQRS tragen ausschließlich Lesezugriffe. Sie laden keine Doctrine-Entities für späteres Speichern, feuern keine Domain-Events und modifizieren keinen Zustand. Der Rückgabetyp ist immer ein Read Model, ein DTO oder ein Skalar — nie eine Doctrine-Entity, die versehentlich modifiziert und gespeichert werden könnte. Symfony's #[AsMessageHandler(bus: 'query.bus')]-Attribut stellt sicher, dass Query-Handler nur auf dem Query-Bus registriert sind und nicht versehentlich als Command-Handler enden. Diese Trennung auf Service-Level ist die härteste Garantie, die CQRS in Symfony bieten kann.
7. CQRS im Controller: Thin Controller, Fat Domain
Controller in einem CQRS-Symfony-Projekt sind radikal dünn. Sie nehmen den HTTP-Request entgegen, bauen aus Form-Daten oder Request-Body ein Command oder eine Query, dispatchen es über den entsprechenden Bus und geben eine HTTP-Response zurück. Keine Geschäftslogik, keine direkten Datenbankzugriffe, keine Service-Aufrufe außer Bus-Dispatch. Ein Controller, der eine Schreiboperation und eine Leseoperation kombiniert — etwa nach einem Formular-Submit die gespeicherten Daten laden und anzeigen — dispatcht zuerst ein Command, dann eine Query: zwei explizite, getrennte Operationen, die die Absicht klar machen.
Dieser Ansatz macht Controller zu reinen Adapterklassen zwischen HTTP und dem Domain-Modell. Die Konsequenz: Controller sind trivial zu testen, weil sie keine Logik enthalten. Business-Logik liegt in Command-Handlern, die unabhängig von HTTP testbar sind. Query-Logik liegt in Query-Handlern, die mit einfachen Unit-Tests abgedeckt werden. Die CQRS-Architektur zieht eine klare Grenze zwischen Infrastructure (Controller, HTTP), Application (Command- und Query-Handler) und Domain (Aggregates, Events, Value Objects) — das klassische Hexagonal-Architecture-Muster auf Symfony-Implementierungsebene.
8. CQRS-Code testen: Commands und Queries isoliert
Das größte Testbarkeits-Argument für CQRS: Commands und Queries sind einfache Value Objects — keine Interfaces zu implementieren, keine Basisklassen zu erben. Ihre Tests sind einfach: Eine Command-Instanz erstellen, den Command-Handler mit gemockten Dependencies instanziieren, den Handler aufrufen und prüfen, ob das Repository mit dem korrekten Aggregat aufgerufen wurde. Query-Handler-Tests prüfen, ob die DBAL-Connection die richtige SQL-Abfrage mit korrekten Parametern empfängt, und ob das Ergebnis korrekt in Read-Model-Objekte umgewandelt wird.
Integrationstests für CQRS-Handler nutzen die KernelTestCase-Basisklasse von Symfony: Der Handler wird aus dem Container geladen, ein echtes Command wird dispatcht und die Auswirkung in der Testdatenbank wird geprüft. Wichtig: Nach jedem Integrationstest muss die Datenbank in den Ausgangszustand zurückgesetzt werden. Das dama/doctrine-test-bundle-Bundle macht das automatisch mit Transaktions-Rollback nach jedem Test — kein explizites Truncaten, kein Fixture-Reload, kein langsamer Test-Teardown. CQRS-Handler sind per se testbar, weil sie genau eine Verantwortlichkeit haben.
| Aspekt | Traditioneller Service | CQRS mit Symfony Messenger | Vorteil CQRS |
|---|---|---|---|
| Verantwortlichkeit | Lesen + Schreiben gemischt | Commands vs. Queries getrennt | Single Responsibility pro Handler |
| Leseperformance | ORM-Entitäten für alles | Raw SQL mit Read Models | Kein ORM-Overhead für Reads |
| Skalierung | Read/Write-DB geteilt | Read-Replica möglich | Separate Skalierung von R und W |
| Testbarkeit | Service mit vielen Deps | Handler mit einer Dep | Isolierter, präziser Test |
| Async | Manuell implementieren | Via Routing konfigurierbar | Kein Handler-Code nötig |
9. CQRS vs. Traditional Service Layer im Vergleich
CQRS ist kein universell überlegener Ansatz — es ist eine Architekturentscheidung mit Konsequenzen in beide Richtungen. Der traditionelle Service-Layer ist einfacher zu verstehen und für kleine Projekte oft ausreichend: Ein UserService mit create(), findById(), update() und delete()-Methoden. CQRS erhöht die initiale Komplexität durch mehr Klassen, mehr Schichten und explizitere Konventionen. Dieser Overhead rechtfertigt sich erst ab einer bestimmten Projektgröße oder wenn spezifische Skalierungsanforderungen bestehen.
Die Entscheidung für CQRS in einem Symfony-Projekt lohnt sich, wenn: Das Team größer als drei bis vier Entwickler ist und Konventionen über Verständnis erzwungen werden müssen. Read- und Write-Last ungleich verteilt ist und die Leseseite separate Optimierungen braucht. Die Domain komplexe Invarianten hat, die mit Aggregaten geschützt werden müssen. Asynchrone Verarbeitung von Schreiboperationen geplant ist. Für klassische CRUD-Anwendungen ohne komplexe Domänenlogik ist der traditionelle Service-Layer mit klarer Methoden-Benennung oft die pragmatischere Wahl.
Mironsoft
Symfony Architektur, CQRS-Implementierung und Domain-driven Design
CQRS-Architektur für Symfony-Projekt aufbauen?
Wir entwerfen und implementieren CQRS-Architekturen in Symfony — von Command- und Query-Bus-Setup über Read-Model-Design bis zu vollständigen Test-Suiten für Commands und Queries in eurem Projekt.
Architektur-Review
Analyse bestehender Symfony-Projekte auf CQRS-Potenziale und Refactoring-Pfad
CQRS-Implementierung
Command Bus, Query Bus, Read Models und Handler-Struktur für euer Domänenmodell
Test-Strategie
Unit- und Integrationstests für Commands und Queries mit dama/doctrine-test-bundle
10. Zusammenfassung
CQRS in Symfony trennt schreibende Commands und lesende Queries auf Architekturebene. Symfony Messenger bietet als Command Bus und Query Bus die notwendige Bus-Infrastruktur ohne externe Bibliotheken. Commands sind immutable Value Objects, die den Willen zur Zustandsänderung ausdrücken — ohne Return-Wert, mit genau einem Handler. Queries sind ebenfalls immutable Value Objects, die immer einen Wert zurückgeben und nie Zustand ändern. Read Models optimieren die Leseseite mit Raw-SQL und flachen DTOs ohne ORM-Overhead. Handler haben genau eine Verantwortlichkeit und sind isoliert testbar.
Der größte Gewinn von CQRS liegt nicht in der Technologie, sondern in der erzwungenen Disziplin: Jede Klasse hat eine klar definierte Verantwortlichkeit, jeder Codepfad ist entweder lesen oder schreiben, niemals beides. Das erleichtert Code-Reviews, vereinfacht Tests und ermöglicht spätere Optimierungen — Read-Replicas, Caching, denormierte Views — ohne Änderungen an der Schreibseite. Für Symfony-Projekte mit komplexer Domänenlogik und mittleren bis großen Teams ist CQRS eine Investition, die sich mit wachsender Codebase immer stärker auszahlt.
Symfony CQRS — Das Wichtigste auf einen Blick
Trennung der Verantwortlichkeit
Commands ändern Zustand, geben nichts zurück. Queries lesen Daten, ändern nichts. Zwei Buses, zwei Handler-Hierarchien, zwei Datenmodelle.
Read Models für Performance
Raw-SQL statt ORM für Leseoperationen. Flache DTOs mit genau den Feldern, die die View braucht. Kein Lazy-Loading, kein Proxy-Overhead.
Symfony Messenger als Bus
Separate command.bus und query.bus Instanzen in messenger.yaml. Commands können async sein, Queries sind immer sync mit Return-Wert.
Thin Controller
Controller dispatcht Command oder Query — keine Geschäftslogik, keine direkten DB-Zugriffe. Business-Logik liegt vollständig in Handlern.