--- name: cqrs description: Patterns CQRS du projet Mangarr — templates Command/CommandHandler et Query/QueryHandler, enregistrement dans Symfony Messenger, séparation read/write model. Utiliser quand on crée ou modifie un use case (Command ou Query) et son handler. allowed-tools: Read, Grep, Glob --- # CQRS — Mangarr ## Principe - **Command** : intention de modifier l'état. Retourne `void`. - **Query** : lecture seule. Retourne un `Response` objet (jamais une entité Doctrine). - Les handlers vivent dans `Application/`, jamais dans `Infrastructure/` directement. - Les handlers sont `readonly class` et reçoivent leurs dépendances via le constructeur (autowiring). --- ## Template Command ```php // src/Domain/{Domain}/Application/Command/{DoSomething}.php namespace App\Domain\{Domain}\Application\Command; readonly class {DoSomething} { public function __construct( public string $someId, public string $someValue, // scalaires ou tableaux uniquement — pas d'objets du Domain ) {} } ``` ## Template CommandHandler ```php // src/Domain/{Domain}/Application/CommandHandler/{DoSomething}Handler.php namespace App\Domain\{Domain}\Application\CommandHandler; use App\Domain\{Domain}\Application\Command\{DoSomething}; use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface; use App\Domain\{Domain}\Domain\Event\{SomethingHappened}; use App\Domain\{Domain}\Domain\Model\{Aggregate}; use Ramsey\Uuid\Uuid; use Symfony\Component\Messenger\MessageBusInterface; readonly class {DoSomething}Handler { public function __construct( private {Name}RepositoryInterface $repository, // autres interfaces Domain uniquement private MessageBusInterface $messageBus, // si event à dispatcher ) {} public function handle({DoSomething} $command): void { // 1. Reconstruire/créer l'Aggregate via Value Objects $aggregate = new {Aggregate}( new {AggregateId}(Uuid::uuid4()->toString()), // ... ); // 2. Appeler la méthode métier (invariants dans le Domain) $aggregate->doSomething(); // 3. Persister via l'interface repository $this->repository->save($aggregate); // 4. Dispatcher un Domain Event si nécessaire $this->messageBus->dispatch(new {SomethingHappened}($aggregate->getId()->getValue())); } } ``` **Règles CommandHandler :** - N'injecte que des interfaces définies dans `Domain/Contract/` (jamais une classe concrète Infrastructure). - Exception autorisée : `MessageBusInterface` de Symfony Messenger. - Ne retourne jamais de données (`void`). --- ## Template Query ```php // src/Domain/{Domain}/Application/Query/{GetSomething}.php namespace App\Domain\{Domain}\Application\Query; readonly class {GetSomething} { public function __construct( public string $id, // critères de filtrage en scalaires ) {} } ``` ## Template QueryHandler ```php // src/Domain/{Domain}/Application/QueryHandler/{GetSomething}Handler.php namespace App\Domain\{Domain}\Application\QueryHandler; use App\Domain\{Domain}\Application\Query\{GetSomething}; use App\Domain\{Domain}\Application\Response\{Something}Response; use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface; use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException; readonly class {GetSomething}Handler { public function __construct( private {Name}RepositoryInterface $repository, ) {} public function handle({GetSomething} $query): {Something}Response { $aggregate = $this->repository->findById($query->id); if (!$aggregate) { throw new {Something}NotFoundException(); } return new {Something}Response( id: $aggregate->getId()->getValue(), // mapper les Value Objects vers scalaires ici ); } } ``` ## Template Response ```php // src/Domain/{Domain}/Application/Response/{Something}Response.php namespace App\Domain\{Domain}\Application\Response; readonly class {Something}Response { public function __construct( public string $id, public string $title, // scalaires et tableaux uniquement — jamais d'objets Domain ) {} } ``` **Règles Response :** - `readonly class`. - Uniquement des types PHP natifs (`string`, `int`, `float`, `bool`, `array`, `?string`…). - C'est le **read model** — il ne sert qu'à transporter des données vers l'Infrastructure. --- ## Enregistrement dans Symfony Messenger ### Command via bus synchrone Les handlers sont auto-découverts par autowiring. Aucune configuration supplémentaire pour les CommandHandlers Application purs. Pour les handlers Symfony Messenger (traitement asynchrone ou via `command.bus`), créer un wrapper dans `Infrastructure/CommandHandler/` : ```php // src/Domain/{Domain}/Infrastructure/CommandHandler/Symfony{DoSomething}Handler.php namespace App\Domain\{Domain}\Infrastructure\CommandHandler; use App\Domain\{Domain}\Application\Command\{DoSomething}; use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler; readonly class Symfony{DoSomething}Handler { public function __construct( private {DoSomething}Handler $handler, ) {} public function __invoke({DoSomething} $command): void { $this->handler->handle($command); } } ``` Déclarer dans `config/services.yaml` : ```yaml App\Domain\{Domain}\Infrastructure\CommandHandler\Symfony{DoSomething}Handler: tags: - { name: messenger.message_handler, bus: command.bus } ``` ### Buses disponibles | Bus / Transport | Usage | |------------------|------------------------------------| | `command.bus` | Commands synchrones ou async | | `events` | Domain Events (async) | | `commands` | Messages async (ex: scraping) | | `async` | Scheduler (tâches planifiées) | --- ## Séparation Read Model / Write Model | Write Model | Read Model | |--------------------------------------|-------------------------------------------| | `Domain/Model/{Aggregate}.php` | `Application/Response/{Name}Response.php` | | Contient les invariants métier | Contient uniquement des données aplaties | | Manipulé par les CommandHandlers | Retourné par les QueryHandlers | | Persisté via `RepositoryInterface` | Jamais persisté directement | Ne jamais retourner un Aggregate depuis un QueryHandler — toujours mapper vers une Response.