Débuter avec CQRS dans Symfony : Les premiers pas vers une architecture flexible

Nicolas LEFEVRE
7 min readNov 14, 2023

--

Dans ce post, je vais vous montrer comment implémenter le pattern CQRS dans une application Symfony. Et pour cela, quoi de mieux que de partir sur le mythique projet de TODO list ?

Mais avant de plonger dans le code, laissez-moi vous présenter CQRS par une analogie. Imaginez la réalisation d’un film : il y a le tournage et le montage. Pour un court-métrage amateur, une seule personne peut s’occuper de tout. Mais pour un blockbuster, ces rôles sont distincts et spécialisés pour une bonne raison. Eh bien, CQRS, c’est un peu comme cela dans le monde du développement web. Intéressant, non ? Allons voir ensemble comment cette idée s’applique dans le cadre de Symfony.

Comprendre CQS (Command Query Separation)

Pour bien saisir le pattern CQRS, il est essentiel de commencer par comprendre le principe de programmation impérative CQS. Développé par Bertrand Meyer, CQS est un concept selon lequel les opérations effectuées par un logiciel doivent être soit des commandes, soit des requêtes, mais jamais les deux à la fois. En d’autres termes, une fonction doit soit modifier l’état d’un objet (command), soit retourner des données (query), mais ne devrait pas faire les deux simultanément. C’est un peu comme dire : “Sois un acteur, ou sois un spectateur, mais ne tente pas d’être les deux en même temps.”

Cette approche a pour but de simplifier la compréhension et la maintenance des systèmes logiciels en garantissant que les fonctions sont conçues pour des tâches spécifiques et clairement définies. En gardant les commandes et les requêtes séparées, on évite les effets secondaires inattendus et on rend le code plus lisible et plus fiable.

L’évolution vers CQRS (Command Query Responsibility Segregation)

Après avoir compris le principe de CQS, il est temps de découvrir comment il évolue en CQRS. Rappelez-vous de notre analogie avec la réalisation d’un film : dans un court-métrage amateur, il est souvent possible pour une même personne de s’occuper du tournage et du montage. C’est pratique, mais cela a ses limites, surtout en termes de complexité et d’échelle.

CQRS, c’est comme passer de la réalisation d’un court-métrage amateur à celle d’un grand film hollywoodien. Dans un blockbuster, le tournage et le montage sont des opérations séparées, confiées à des équipes spécialisées. Cette séparation permet de gérer des projets d’une envergure et d’une complexité bien plus grandes. Le tournage (“commands”) crée le contenu, tandis que le montage (“queries”) le structure et le présente de la meilleure façon possible.

Dans le monde du développement, CQRS applique cette même logique de séparation et de spécialisation à grande échelle. Les commandes (créer, modifier, supprimer des données) et les requêtes (lire des données) sont traitées par des modules distincts, optimisés pour leurs fonctions respectives. Cette séparation offre non seulement une meilleure organisation mais aussi une capacité à gérer efficacement des systèmes plus complexes et à grande échelle.

Mise en Pratique dans Symfony

Il est temps de passer de la théorie à la pratique. Pour vraiment saisir la puissance de CQRS dans Symfony, rien ne vaut un bon exemple concret. C’est pourquoi je vous invite à jeter un œil à mon projet GitHub : todo-cqrs. Ce repo est un exemple d’utilisation du pattern CQRS dans une application Symfony.

Dans ce repo, vous trouverez une structure clairement définie séparant les commandes des requêtes, un principe fondamental de CQRS. Chaque commande et requête a son propre espace, rendant le projet non seulement organisé, mais aussi intuitif à naviguer. Pour vous donner un aperçu :

  • Commandes : Des actions telles que CreateTaskCommand, UpdateTaskCommand, DeleteTaskCommand – chacune représentant une action distincte dans l'application.
  • Requêtes : Des opérations de lecture comme GetTaskByIdQuery, GetAllTasksQuery, reflétant la partie 'Query' de CQRS.

Pour mieux comprendre comment ces éléments fonctionnent ensemble, je vais vous guider à travers l’exemple d’une commande spécifique — CreateTaskCommand – et d'une requête – GetTaskByIdQuery. Ces exemples illustreront clairement comment les principes de CQRS sont appliqués dans un contexte réel de développement Symfony.

Décryptage de CreateTaskCommand

  • La commande : CreateTaskCommand

CreateTaskCommand a pour but de transmettre les données nécessaires à la création d'une tâche.

// CreateTaskCommand.php
final readonly class CreateTaskCommand
{
public function __construct(
public string $id,
public string $title,
public string $description
) {}
}

Chaque tâche est définie par un id, un title, et une description. Pas de logique métier ici, juste un transporteur de données (DTO), prêt à être manipulé par notre gestionnaire. J’ai choisi ici de donner la responsabilité au client de générer un id (UUID). Il y a plusieurs avantages à cela qu’on ne couvrira pas ici mais éventuellement dans un autre post :) .

  • Le handler : CreateTaskHandler

Le CreateTaskHandler prend la relève, transformant notre commande en action concrète.

// CreateTaskHandler.php
final class CreateTaskHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}

public function handle(CreateTaskCommand $command): void
{
$task = new Task();
$task->setId(Uuid::fromString($command->id));
$task->setTitle($command->title);
$task->setDescription($command->description);
$task->setStatus(TaskStatus::IN_PROGRESS);
$task->setCreatedAt(new \DateTimeImmutable());

$this->entityManager->persist($task);
$this->entityManager->flush();
}
}

Ici, la commande est décomposée et ses données sont utilisées pour créer une nouvelle instance de Task. Le handler s'assure que la tâche est correctement sauvegardée dans la base de données.

  • Le controller : CreateTaskController

Enfin, CreateTaskController orchestre le processus en exposant un point d'entrée API pour notre commande.

// CreateTaskController.php
#[Route('/api/tasks', name: 'create_task', methods: ['POST'], format: 'json')]
final readonly class CreateTaskController
{
public function __construct(
private CreateTaskHandler $handler,
) {
}

public function __invoke(#[MapRequestPayload] CreateTaskCommand $command): Response
{
$this->handler->handle($command);

return new Response(status: Response::HTTP_CREATED);
}
}

Ce contrôleur utilise l’attribut #[MapRequestPayload], disponible depuis Symfony 6.3, pour mapper automatiquement la payload de la requête sur notre CreateTaskCommand, simplifiant ainsi la réception et le traitement des données de la requête.

Décryptage de GetTaskByIdQuery

  • La requête : GetTaskByIdQuery

GetTaskByIdQuery sert un objectif clair : récupérer les détails d'une tâche spécifique par son identifiant.

// GetTaskByIdQuery.php
final readonly class GetTaskByIdQuery
{
public function __construct(
public string $id
) {}
}

Encore une fois ici, c’est un simple DTO qui ne possède qu’une seule propriété, l’id, qui est la clé pour retrouver notre tâche.

  • Le handler : GetTaskByIdHandler

Le GetTaskByIdHandler est là où la magie opère. Il prend la requête et récupère la tâche correspondante de la base de données.

// GetTaskByIdHandler.php
final readonly class GetTaskByIdHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}

public function handle(GetTaskByIdQuery $query): Task
{
$task = $this->entityManager->getRepository(Task::class)->find($query->id);

if ($task === null) {
throw new \Exception('Task not found');
}

return $task;
}
}

Ici, le handler utilise l’identifiant fourni pour chercher la tâche désirée. Si la tâche n’est pas trouvée, une exception est levée.

  • Le controller : GetTaskByIdController

GetTaskByIdController offre un point d'entrée API pour notre requête.

// GetTaskByIdController.php
#[Route('/api/tasks/{taskId}', name: 'get_task', methods: ['GET'], format: 'json')]
final readonly class GetTaskByIdController
{
public function __construct(
private GetTaskByIdHandler $handler,
) {
}

public function __invoke(string $taskId): JsonResponse
{
try {
$task = $this->handler->handle(new GetTaskByIdQuery($taskId));
} catch (\Exception $e) {
return new JsonResponse(null, Response::HTTP_NOT_FOUND);
}

return new JsonResponse(
[
'id' => $task->getId(),
'title' => $task->getTitle(),
'description' => $task->getDescription(),
'status' => $task->getStatus(),
],
Response::HTTP_OK,
);
}
}

Dans ce contrôleur, une instance de GetTaskByIdQuery est créée et passée au handler. Le résultat est ensuite transformé en une réponse JSON contenant les détails de la tâche.

Pour voir tout le projet : c’est par ici sur github.

Pour Aller Plus Loin avec CQRS et Symfony

Après avoir exploré les bases de CQRS dans Symfony avec notre application de liste de tâches, voici quelques pistes pour approfondir vos connaissances et compétences.

Tests dans CQRS

Les tests jouent un rôle crucial, surtout dans une architecture comme CQRS où la séparation des responsabilités est clé. Pensez à intégrer des tests unitaires, des tests d’intégrations et des tests end to end. Cela assure non seulement que chaque partie fonctionne comme prévu, mais contribue également à la robustesse globale de votre application.

Symfony Messenger pour CQRS

Le composant Symfony Messenger est un atout pour gérer les messages et les commandes de manière asynchrone. Son intégration dans une architecture CQRS peut offrir une plus grande flexibilité et décharger votre application des traitements lourds, rendant les interactions utilisateur plus fluides.

Architecture Hexagonale

Pour une flexibilité et une évolutivité encore plus grandes, envisagez d’adopter une architecture hexagonale. Cela permet d’isoler encore mieux votre logique métier des détails d’infrastructure, rendant votre système plus résilient aux changements et plus facile à tester.

Documentation de l’API

Une API bien documentée est essentielle, surtout dans des architectures complexes. Utilisez des outils comme Swagger ou API Platform pour créer une documentation claire et interactive de votre API. Cela facilite non seulement la vie des développeurs qui utilisent votre API, mais contribue également à la maintenabilité et l’évolutivité de l’application.

Séparation des Bases de Données : Relationnelles vs NoSQL

L’un des aspects avancés de CQRS est la séparation des bases de données pour l’écriture et la lecture. Cette approche peut significativement améliorer les performances et la scalabilité de votre application.

  • Pour l’écriture : Utilisez une base de données relationnelle. Ces systèmes sont conçus pour garantir la cohérence et l’intégrité des données, ce qui est crucial pour les opérations d’écriture.
  • Pour la lecture : Envisagez des systèmes NoSQL comme Elasticsearch ou MongoDB. Ces bases de données sont optimisées pour des requêtes rapides et peuvent gérer de grands volumes de données, les rendant idéales pour les opérations de lecture.

Cette stratégie, bien que puissante, ajoute une couche de complexité, notamment en termes de synchronisation entre les bases de données. Toutefois, grâce au pattern CQRS, cette évolution est non seulement gérable mais aussi logiquement cohérente avec l’approche de séparation des responsabilités.

--

--

Nicolas LEFEVRE

Senior Fullstack Developer | Proficient in Symfony & React | Focused on Software Architecture & SaaS Development