6.5 KiB
6.5 KiB
name, description, allowed-tools
| name | description | allowed-tools |
|---|---|---|
| cqrs | 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. | Read, Grep, Glob |
CQRS — Mangarr
Principe
- Command : intention de modifier l'état. Retourne
void. - Query : lecture seule. Retourne un
Responseobjet (jamais une entité Doctrine). - Les handlers vivent dans
Application/, jamais dansInfrastructure/directement. - Les handlers sont
readonly classet reçoivent leurs dépendances via le constructeur (autowiring).
Template Command
// 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
// 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 :
MessageBusInterfacede Symfony Messenger. - Ne retourne jamais de données (
void).
Template Query
// 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
// 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
// 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/ :
// 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 :
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.