L'architecture logicielle
Ranger le code pour qu'il reste modifiable, et savoir quand ce rangement coûte plus qu'il ne rapporte.
Un jour, en réunion ou dans une offre d'emploi, tu entendras « on fait de l'hexagonal », « on est en Clean Architecture » ou « on passe au DDD ». Ces mots impressionnent, mais ils désignent des idées simples : des façons de découper le code et de décider quelle partie a le droit de connaître quelle autre. Chacune répond à un problème réel, et chacune a un prix.
Cette fiche les présente toutes sur le même exemple, passer une commande dans une boutique, pour que tu puisses les comparer côte à côte. Le but n'est pas de les appliquer partout. Beaucoup de projets Symfony qui marchent très bien n'en utilisent aucune. Le but est de savoir reconnaître le moment où l'une d'elles vaut son coût.
1.Le point de départ : tout dans le contrôleur
Voici le code que l'on écrit naturellement pour passer une commande : le contrôleur lit la requête, vérifie le stock, enregistre, et envoie l'e-mail de confirmation.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Order;
use App\Entity\Product;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
final class OrderController extends AbstractController
{
#[Route('/orders', methods: ['POST'])]
public function place(
Request $request,
#[CurrentUser] User $user,
EntityManagerInterface $em,
MailerInterface $mailer,
): JsonResponse {
$order = new Order($user);
foreach ($request->toArray()['lines'] ?? [] as $sku => $quantity) {
// Règle métier : on ne vend pas ce qu'on n'a pas
$product = $em->getRepository(Product::class)->findOneBy(['sku' => $sku]);
if ($product === null || $product->getStock() < $quantity) {
return $this->json(['error' => "Stock insuffisant pour $sku"], 409);
}
$product->setStock($product->getStock() - $quantity);
$order->addLine($product, $quantity);
}
// Technique : base de données
$em->persist($order);
$em->flush();
// Technique : e-mail
$mailer->send((new Email())
->to($user->getEmail())
->subject('Commande confirmée')
->text('Merci pour votre commande.'));
return $this->json(['id' => $order->getId()], 201);
}
}Ce code marche. Trois problèmes apparaissent quand le projet grandit :
- La règle est prisonnière du contrôleur. « On ne vend pas ce qu'on n'a pas » est écrit ici, et nulle part ailleurs. Le jour où une commande console importe les commandes d'une marketplace, il faudra recopier la règle, ou l'oublier.
- Elle ne se teste pas seule. Pour vérifier un simple calcul de stock, il faut démarrer Symfony, une base de données et un mailer : seul un test fonctionnel est possible (fiche 3).
- Trois métiers se mélangent : traduire HTTP, appliquer les règles, parler à la technique. Modifier l'un risque de casser les autres.
Ce n'est pas une faute. Pour un prototype ou un petit CRUD (un écran qui crée, lit, modifie et supprime des lignes), c'est le code le plus simple, donc souvent le bon. Il devient un problème quand le métier grossit.
Une architecture répond à deux questions : comment découper le code en parties, et qui a le droit de connaître qui. La seconde est la plus importante. On dit qu'une classe A dépend de B quand A cite le nom de B : un use, un type de paramètre, un new. Si B change, A risque de devoir changer. Toutes les architectures de cette fiche sont des règles sur le sens de ces dépendances.
2.L'architecture en couches
Première idée : ranger le code par rôle, en couches empilées. Chaque couche n'appelle que celle du dessous.
<?php
declare(strict_types=1);
// Couche application : le service qui porte le cas d'usage
namespace App\Service;
use App\Entity\Order;
use App\Entity\User;
use App\Exception\OutOfStock;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class OrderService
{
public function __construct(
private EntityManagerInterface $em,
private ProductRepository $products,
private MailerInterface $mailer,
) {
}
/** @param array<string, int> $lines référence du produit => quantité */
public function place(User $customer, array $lines): Order
{
$order = new Order($customer);
foreach ($lines as $sku => $quantity) {
$product = $this->products->findOneBy(['sku' => $sku]);
if ($product === null || $product->getStock() < $quantity) {
throw new OutOfStock("Stock insuffisant pour $sku");
}
$product->removeStock($quantity);
$order->addLine($product, $quantity);
}
$this->em->persist($order);
$this->em->flush();
$this->mailer->send((new Email())
->to($customer->getEmail())
->subject('Commande confirmée')
->text('Merci pour votre commande.'));
return $order;
}
}
// Couche présentation : le contrôleur traduit HTTP, et rien d'autre
namespace App\Controller;
use App\Entity\User;
use App\Exception\OutOfStock;
use App\Service\OrderService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
final class OrderController extends AbstractController
{
#[Route('/orders', methods: ['POST'])]
public function place(
Request $request,
#[CurrentUser] User $user,
OrderService $orders,
): JsonResponse {
try {
$order = $orders->place($user, $request->toArray()['lines'] ?? []);
} catch (OutOfStock $e) {
return $this->json(['error' => $e->getMessage()], 409);
}
return $this->json(['id' => $order->getId()], 201);
}
}- La présentation (contrôleurs, commandes console, Twig) traduit le monde extérieur en appels PHP. Le contrôleur ne fait plus que ça.
- L'application (les services) porte le cas d'usage : les règles et l'ordre des étapes. Un cas d'usage, c'est une action que l'utilisateur peut faire : passer une commande, annuler une réservation.
- L'accès aux données (les repositories Doctrine) parle à la base.
Le gain est immédiat : la règle n'existe qu'une fois, et une commande console peut appeler OrderService sans rien recopier. C'est d'ailleurs ce que demandent les bonnes pratiques de Symfony : des contrôleurs fins, qui ne font que coordonner, et la logique dans des services.
La limite est visible sur le schéma : le service dépend directement de EntityManagerInterface, de ProductRepository et du mailer. Le métier dépend de la technique. Tester OrderService demande toujours une base de données, et changer d'outil oblige à rouvrir le métier.
Utile dans la grande majorité des applications Symfony : c'est le bon point de départ, pour un coût très faible.
Insuffisant quand tu veux tester les règles sans base de données, ou quand le métier devient assez riche pour que sa dépendance à Doctrine te gêne au quotidien. C'est l'objet de la section suivante.
Un #[IsGranted] sur le contrôleur protège la route, pas le cas d'usage. Si une autre entrée appelle le même service (une API, un message, une commande console exposée à un client), la vérification saute. Pour une règle qui dépend de l'objet, comme « seul le client peut annuler sa commande », vérifie-la aussi dans le cas d'usage, par exemple en appelant un Voter (fiche 10).
3.L'inversion de dépendances
Tu as vu ce principe dans la fiche 6 : dépendre d'une interface plutôt que d'une classe concrète. À l'échelle d'une architecture, une question compte en plus : qui écrit l'interface ?
- Le cœur décrit ce dont il a besoin, dans ses propres mots : « enregistrer une commande », pas « un EntityManager ». Cette interface s'appelle un port.
- La technique fournit une classe qui implémente ce port, rangée à l'extérieur du cœur : un adaptateur.
- Symfony les relie. Avec la configuration par défaut, quand une seule classe implémente une interface, l'autowiring (le câblage automatique des services) l'injecte partout où l'interface est demandée.
<?php
declare(strict_types=1);
// Le port : une interface décrite par le cœur, dans SES mots
namespace App\Application\Port;
use App\Domain\Order;
interface OrderRepository
{
public function save(Order $order): void;
}
// L'adaptateur : la technique, rangée à l'extérieur, qui implémente le port
namespace App\Infrastructure\Doctrine;
use App\Application\Port\OrderRepository;
use App\Domain\Order;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(private EntityManagerInterface $em)
{
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
}La flèche de dépendance a changé de sens : c'est Doctrine qui « connaît » le métier, plus l'inverse. D'où le nom d'inversion de dépendances. Le cas d'usage peut maintenant être testé avec un adaptateur en mémoire, un simple tableau, en quelques millisecondes.
Range ces adaptateurs de test dans tests/, pas dans src/. Sinon, Symfony trouve deux classes pour la même interface, ne sait plus laquelle injecter, et demande que tu le précises dans services.yaml.
Utile pour les dépendances qui gênent vraiment : la base de données quand tu veux tester les règles sans elle, un prestataire qui pourrait changer (paiement, envoi de SMS), une API externe lente ou payante.
Inutile pour tout le reste. Chaque port ajoute une interface, un fichier, et un détour quand tu navigues dans le code (« aller à l'implémentation »). Une interface pour une classe qui n'aura jamais qu'une version, et que tu n'as pas besoin de remplacer en test, est du poids mort.
4.L'architecture hexagonale
L'architecture hexagonale, aussi appelée ports et adaptateurs, a été décrite par Alistair Cockburn en 2005. Elle généralise la section précédente : l'application est au centre, et elle ne communique avec le monde extérieur que par des ports.
- Les adaptateurs d'entrée appellent l'application : un contrôleur HTTP, une commande console, un consommateur de messages, un test.
- Les adaptateurs de sortie sont appelés par l'application, à travers ses ports : la base de données, l'envoi d'e-mails, une API de paiement.
Pourquoi un hexagone ? Pour rien de précis : il fallait une forme avec assez de côtés pour dessiner plusieurs ports. Le nombre de côtés n'a aucun sens.
Voici l'exemple complet en PHP pur. Les adaptateurs de sortie sont en mémoire, ce qui permet de l'exécuter sans Symfony ni base de données :
<?php
declare(strict_types=1);
// ---- Domaine : les règles, en PHP pur, sans aucune dépendance technique ----
final class OutOfStock extends DomainException
{
}
final class Order
{
/** @param array<string, int> $lines référence du produit => quantité */
public function __construct(
public readonly string $id,
public readonly string $customerEmail,
public readonly array $lines,
) {
if ($lines === []) {
throw new DomainException('Une commande contient au moins un article.');
}
}
}
// ---- Ports : ce dont le cas d'usage a besoin, décrit par lui ----
interface Stock
{
public function available(string $sku): int;
public function remove(string $sku, int $quantity): void;
}
interface OrderRepository
{
public function save(Order $order): void;
}
interface OrderConfirmation
{
public function send(Order $order): void;
}
// ---- Application : le cas d'usage « passer une commande » ----
final class PlaceOrder
{
public function __construct(
private Stock $stock,
private OrderRepository $orders,
private OrderConfirmation $confirmation,
) {
}
/** @param array<string, int> $lines */
public function __invoke(string $orderId, string $email, array $lines): Order
{
foreach ($lines as $sku => $quantity) {
if ($this->stock->available($sku) < $quantity) {
throw new OutOfStock("Stock insuffisant pour $sku");
}
}
$order = new Order($orderId, $email, $lines);
foreach ($lines as $sku => $quantity) {
$this->stock->remove($sku, $quantity);
}
$this->orders->save($order);
$this->confirmation->send($order);
return $order;
}
}
// ---- Adaptateurs de sortie : en mémoire ici, Doctrine et Symfony Mailer en production ----
final class InMemoryStock implements Stock
{
/** @param array<string, int> $quantities */
public function __construct(private array $quantities)
{
}
public function available(string $sku): int
{
return $this->quantities[$sku] ?? 0;
}
public function remove(string $sku, int $quantity): void
{
$this->quantities[$sku] -= $quantity;
}
}
final class InMemoryOrderRepository implements OrderRepository
{
/** @var list<Order> */
public array $saved = [];
public function save(Order $order): void
{
$this->saved[] = $order;
}
}
final class EchoConfirmation implements OrderConfirmation
{
public function send(Order $order): void
{
echo " [e-mail à {$order->customerEmail}] commande {$order->id} confirmée\n";
}
}
// ---- Adaptateurs d'entrée : une requête HTTP simulée, puis une commande console ----
$stock = new InMemoryStock(['KB-01' => 3, 'MS-02' => 0]);
$orders = new InMemoryOrderRepository();
$placeOrder = new PlaceOrder($stock, $orders, new EchoConfirmation());
$json = '{"email": "alice@example.com", "lines": {"KB-01": 2}}';
$body = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
$order = $placeOrder('A-1', $body['email'], $body['lines']);
echo "HTTP 201 : commande {$order->id} créée\n";
try {
$placeOrder('A-2', 'bob@example.com', ['MS-02' => 1]);
} catch (OutOfStock $e) {
echo "HTTP 409 : {$e->getMessage()}\n";
}
// La console appelle le MÊME cas d'usage : aucune règle n'est recopiée
$placeOrder('C-1', 'chloe@example.com', ['KB-01' => 1]);
echo "console : commande C-1 créée\n";
echo "Stock restant de KB-01 : {$stock->available('KB-01')}\n";
echo count($orders->saved) . " commandes enregistrées\n";[e-mail à alice@example.com] commande A-1 confirmée HTTP 201 : commande A-1 créée HTTP 409 : Stock insuffisant pour MS-02 [e-mail à chloe@example.com] commande C-1 confirmée console : commande C-1 créée Stock restant de KB-01 : 0 2 commandes enregistrées
La requête HTTP simulée et la commande console appellent le même cas d'usage : aucune règle n'est recopiée. En production, on remplace les trois adaptateurs en mémoire par des classes qui utilisent Doctrine et Symfony Mailer, sans changer une ligne de PlaceOrder. Dans un vrai projet, ces classes sont réparties ainsi :
src/
├── Domain/ les règles, en PHP pur
│ ├── Order.php
│ └── OutOfStock.php
├── Application/ les cas d'usage et leurs ports
│ ├── PlaceOrder.php
│ └── Port/
│ ├── Stock.php
│ ├── OrderRepository.php
│ └── OrderConfirmation.php
└── Infrastructure/ les adaptateurs : Symfony, Doctrine, Mailer
├── Http/OrderController.php
├── Console/ImportOrdersCommand.php
├── Doctrine/DoctrineOrderRepository.php
├── Doctrine/DbalStock.php
└── Mailer/MailerOrderConfirmation.php
Deux remarques honnêtes sur cet exemple :
- Il est simplifié. Entre la vérification du stock et sa diminution, une autre commande peut passer et prendre le dernier article (fiche 7, transactions). L'adaptateur SQL fera la vérification et la diminution en une seule requête,
UPDATE … SET stock = stock - ? WHERE sku = ? AND stock >= ?, et lèveraOutOfStocksi aucune ligne n'a changé. - Les entités Doctrine posent une question. Une entité du domaine qui porte des attributs
#[ORM\Entity]cite Doctrine. Les puristes déplacent la configuration de Doctrine dans des fichiers XML, hors du domaine. Beaucoup d'équipes acceptent les attributs : ce sont des métadonnées, ils ne changent pas le comportement de la classe, et l'entité reste testable sans base. C'est un compromis, choisis-le en connaissance de cause.
Utile quand le métier est riche et vit longtemps, quand plusieurs entrées (site, API, application mobile, imports) appellent les mêmes règles, ou quand les tests rapides du métier comptent beaucoup.
Coûteux pour un CRUD : on écrit une dizaine de fichiers là où un contrôleur et un formulaire suffisaient, et chaque nouveau développeur doit apprendre la structure avant d'être efficace.
5.La Clean Architecture
La Clean Architecture, décrite par Robert C. Martin en 2012, reprend la même idée et la dessine en cercles. Elle nomme une seule règle, la règle de dépendance : le code d'un cercle ne cite jamais le nom d'une classe d'un cercle plus extérieur. Les dépendances pointent toutes vers le centre.
Plus on est près du centre, plus le code est stable : la règle « on ne vend pas ce qu'on n'a pas » changera moins souvent que la version de Symfony ou que le choix de la base de données. On protège donc ce qui est stable de ce qui change souvent.
Tu croiseras aussi l'architecture en oignon (Jeffrey Palermo, 2008). Ces trois noms désignent presque la même chose :
| Nom | Vocabulaire | Ce qu'elles partagent |
|---|---|---|
| Hexagonale (2005) | application, ports, adaptateurs d'entrée et de sortie | Le métier au centre, ignorant la technique. Les dépendances pointent vers l'intérieur. Le métier se teste seul. |
| Oignon (2008) | couches concentriques, domaine au centre | |
| Clean (2012) | entités, cas d'usage, adaptateurs, frameworks et pilotes |
Si une équipe te parle de l'une, tu comprends donc les trois. Ce qui compte, c'est le sens des dépendances, pas les noms des dossiers : ne recopie pas les cercles à la lettre en dossiers Entities/, UseCases/, InterfaceAdapters/. Pour vérifier la règle automatiquement, un outil d'analyse comme Deptrac peut refuser tout use Doctrine\… ou use Symfony\… dans le domaine, à chaque passage de l'intégration continue.
Mêmes cas que l'hexagonale : c'est la même architecture, avec un autre dessin. Choisis le vocabulaire que ton équipe connaît déjà, et tiens-t'en à lui.
6.Le DDD : partir du métier
Le DDD (Domain-Driven Design, conception pilotée par le domaine), décrit par Eric Evans en 2003, n'est pas d'abord une façon de ranger des dossiers. C'est une façon de travailler le métier avec les gens qui le connaissent, puis de le refléter fidèlement dans le code. Il repose sur trois idées.
- Le langage partagé (ubiquitous language) : les mêmes mots dans les réunions et dans le code. Si le service client dit « réserver un article » et que le code dit
lockItem(), chaque conversation demande une traduction, et les traductions créent des bugs (fiche 14). - Les contextes délimités (bounded contexts) : un même mot n'a pas le même sens partout. Le « produit » du marketing n'est pas celui de l'entrepôt.
- Des briques pour modéliser : entités, objets-valeurs, agrégats, détaillées plus bas.
Chaque contexte a son propre modèle, dans le langage de son équipe. Ils ne partagent qu'un identifiant, ici la référence du produit. Dans le code, ce découpage peut devenir des modules séparés (src/Catalog/, src/Logistics/, src/Billing/), chacun avec sa propre classe Product.
Les briques de modélisation, maintenant. Tu connais déjà l'entité : un objet qui a une identité et qui change dans le temps, comme une commande (fiche 6). Deux autres briques sont utiles même hors du DDD :
- L'objet-valeur (value object) est défini par sa valeur, pas par une identité : deux montants de 9,90 € sont interchangeables. Il est immuable (on ne le modifie pas, on en crée un nouveau) et valide dès sa création. Un montant négatif ne peut tout simplement pas exister.
- L'agrégat est un groupe d'objets qui ne change que par une seule porte d'entrée, sa racine. Ici, on n'ajoute jamais une ligne de commande directement : on passe par la commande, qui vérifie les règles qui concernent l'ensemble de ses lignes.
<?php
declare(strict_types=1);
// Un objet-valeur : défini par sa valeur, immuable, valide dès sa création
final class Money
{
private function __construct(public readonly int $cents)
{
}
public static function euros(int $cents): self
{
if ($cents < 0) {
throw new InvalidArgumentException('Un montant ne peut pas être négatif.');
}
return new self($cents);
}
public function add(self $other): self
{
return new self($this->cents + $other->cents); // un NOUVEL objet
}
public function times(int $factor): self
{
return new self($this->cents * $factor);
}
public function equals(self $other): bool
{
return $this->cents === $other->cents;
}
public function __toString(): string
{
return number_format($this->cents / 100, 2, ',', ' ') . ' €';
}
}
// Un agrégat : la commande garde la cohérence de toutes ses lignes
final class Order
{
private const MAX_PER_PRODUCT = 10;
/** @var array<string, array{quantity: int, unitPrice: Money}> */
private array $lines = [];
private bool $confirmed = false;
public function add(string $sku, int $quantity, Money $unitPrice): void
{
if ($this->confirmed) {
throw new DomainException('Une commande confirmée ne change plus.');
}
$total = ($this->lines[$sku]['quantity'] ?? 0) + $quantity;
if ($quantity < 1 || $total > self::MAX_PER_PRODUCT) {
$max = self::MAX_PER_PRODUCT;
throw new DomainException("Entre 1 et $max exemplaires de $sku.");
}
$this->lines[$sku] = ['quantity' => $total, 'unitPrice' => $unitPrice];
}
public function confirm(): void
{
if ($this->lines === []) {
throw new DomainException('Une commande vide ne peut pas être confirmée.');
}
$this->confirmed = true;
}
public function total(): Money
{
$total = Money::euros(0);
foreach ($this->lines as $line) {
$total = $total->add($line['unitPrice']->times($line['quantity']));
}
return $total;
}
}
function attempt(string $label, callable $action): void
{
try {
$action();
echo "OK $label\n";
} catch (InvalidArgumentException | DomainException $e) {
echo "Refusé $label : {$e->getMessage()}\n";
}
}
$order = new Order();
attempt('confirmer une commande vide', fn () => $order->confirm());
attempt('ajouter 2 claviers', fn () => $order->add('KB-01', 2, Money::euros(4990)));
attempt('ajouter 9 claviers de plus', fn () => $order->add('KB-01', 9, Money::euros(4990)));
attempt('ajouter une souris à -5 €', fn () => $order->add('MS-02', 1, Money::euros(-500)));
attempt('ajouter une souris', fn () => $order->add('MS-02', 1, Money::euros(1990)));
attempt('confirmer', fn () => $order->confirm());
attempt('ajouter un câble après confirmation',
fn () => $order->add('CB-04', 1, Money::euros(990)));
echo "Total : {$order->total()}\n";
echo 'Money::euros(990) égale Money::euros(990) ? '
. (Money::euros(990)->equals(Money::euros(990)) ? 'oui' : 'non') . "\n";Refusé confirmer une commande vide : Une commande vide ne peut pas être confirmée. OK ajouter 2 claviers Refusé ajouter 9 claviers de plus : Entre 1 et 10 exemplaires de KB-01. Refusé ajouter une souris à -5 € : Un montant ne peut pas être négatif. OK ajouter une souris OK confirmer Refusé ajouter un câble après confirmation : Une commande confirmée ne change plus. Total : 119,70 € Money::euros(990) égale Money::euros(990) ? oui
Chaque refus vient de l'objet lui-même : aucun contrôleur ni service ne peut l'oublier. Remarque aussi que add() et times() renvoient un nouvel objet Money au lieu de modifier l'ancien. Avec Doctrine, un objet-valeur s'enregistre comme un embeddable : ses champs deviennent des colonnes de la table de l'entité qui le contient.
Les objets-valeurs sont rentables presque partout : un Money, un EmailAddress, une DateRange suppriment des validations répétées dans tout le code, pour un coût minime.
Le découpage en contextes se justifie dans un gros projet, avec plusieurs équipes ou un métier vraiment complexe (assurance, logistique, finance). Il demande du temps avec les experts du métier et une modélisation soignée. Pour un site vitrine ou un back-office simple, c'est beaucoup de cérémonie pour peu de gain.
Attention au faux DDD : des dossiers Domain/ remplis d'entités avec seulement des getters et des setters reprennent le vocabulaire sans les règles. Le DDD, ce sont d'abord des objets qui portent le métier.
7.CQRS : séparer l'écriture et la lecture
Un constat de départ : écrire et lire n'ont pas les mêmes besoins. Pour passer une commande, il faut vérifier des règles, et l'agrégat Order est fait pour ça. Pour afficher « mes commandes avec leur total », il faut des données assemblées vite, et charger des entités une par une pour faire un total est lent (le problème N+1, fiche 8).
CQRS (Command Query Responsibility Segregation) sépare ces deux chemins :
- Une commande modifie l'état, passe par les règles du métier, et ne renvoie rien.
- Une requête (query) lit des données, ne modifie rien, et ne passe par aucune règle.
Avec Symfony, le composant Messenger fournit le bus qui transporte les commandes. Un message est un simple objet de données, et son handler (la classe qui le traite) est marqué par l'attribut #[AsMessageHandler]. Côté lecture, une classe de requête utilise directement la connexion DBAL, sans passer par les entités. Elle reprend la base de la fiche 7 :
<?php
declare(strict_types=1);
// Côté écriture : une commande (un message) et son handler
namespace App\Application\Command;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class PlaceOrderCommand
{
/** @param array<string, int> $lines */
public function __construct(
public string $orderId,
public int $customerId,
public array $lines,
) {
}
}
#[AsMessageHandler]
final class PlaceOrderHandler
{
// Mêmes dépendances (les ports) et mêmes étapes que le cas d'usage PlaceOrder
public function __invoke(PlaceOrderCommand $command): void
{
// vérifier le stock, créer la commande, l'enregistrer, envoyer la confirmation
}
}
// Côté lecture : une requête SQL directe, taillée pour l'écran, sans entités
namespace App\Application\Query;
use Doctrine\DBAL\Connection;
final class CustomerOrdersQuery
{
public function __construct(private Connection $db)
{
}
/** @return list<array<string, mixed>> */
public function forCustomer(int $customerId): array
{
return $this->db->fetchAllAssociative(
'SELECT o.id, o.created_at,
SUM(i.quantity * i.unit_price_in_cents) AS total_in_cents
FROM customer_order o
JOIN order_item i ON i.order_id = o.id
WHERE o.customer_id = ?
GROUP BY o.id, o.created_at
ORDER BY o.created_at DESC',
[$customerId],
);
}
}
// Les deux côtés dans un contrôleur
namespace App\Controller;
use App\Application\Command\PlaceOrderCommand;
use App\Application\Query\CustomerOrdersQuery;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Uid\Uuid;
final class OrderController extends AbstractController
{
#[Route('/orders', methods: ['POST'])]
public function place(
Request $request,
#[CurrentUser] User $user,
MessageBusInterface $bus,
): JsonResponse {
// L'identifiant est créé avant : la commande, elle, ne renvoie rien
$orderId = Uuid::v7()->toRfc4122();
$lines = $request->toArray()['lines'] ?? [];
$bus->dispatch(new PlaceOrderCommand($orderId, $user->getId(), $lines));
return $this->json(['id' => $orderId], 201);
}
#[Route('/orders', methods: ['GET'])]
public function list(#[CurrentUser] User $user, CustomerOrdersQuery $query): JsonResponse
{
// L'identifiant du client vient de la session, jamais de l'URL
return $this->json($query->forCustomer($user->getId()));
}
}- La commande ne renvoie rien : l'identifiant est donc créé avant, avec le composant Uid de Symfony. Un UUID version 7 est unique et se trie par date de création.
- Par défaut, Messenger traite le message tout de suite, dans la même requête. On peut ensuite l'envoyer en arrière-plan en le routant vers un transport, sans changer ni le contrôleur ni le handler.
- CQRS n'oblige pas à avoir deux bases de données. Commence avec une seule. Une base dédiée à la lecture ne vient que si les mesures le demandent, et elle a un prix : les données lues ont un léger retard sur les écritures.
- CQRS n'est pas non plus l'event sourcing, qu'on lui associe souvent : enregistrer la suite des événements (« article ajouté », « commande payée ») au lieu de l'état actuel. C'est une autre technique, encore plus coûteuse, utile surtout quand l'historique complet est une obligation métier.
La version légère est utile partout : des classes de requête, à côté des repositories, pour les écrans de liste, de recherche et de tableaux de bord. Elle évite d'utiliser les entités pour ce qu'elles font mal.
La version complète (bus de commandes, handlers, base de lecture séparée) se justifie quand les écritures doivent passer en arrière-plan, ou quand les lectures sont très différentes des écritures. Pour un CRUD où lire, c'est afficher l'entité, elle double le code sans rien apporter.
Une requête SQL directe ne passe ni par les entités, ni par les Voters. C'est à elle de filtrer : l'identifiant du client vient de la session (#[CurrentUser]), jamais d'un paramètre de l'URL, sinon n'importe qui lit les commandes des autres en changeant un nombre (IDOR, fiche 10). Sélectionne aussi seulement les colonnes utiles : un SELECT * renvoyé tel quel en JSON finit un jour par exposer une colonne sensible.
8.Choisir, et faire évoluer
Chaque architecture ajoute de la structure. Elle n'est rentable que si le projet a vraiment le problème qu'elle résout.
| Architecture | Ce qu'elle rapporte | Ce qu'elle coûte | À éviter quand |
|---|---|---|---|
| Tout dans le contrôleur | Le code le plus court et le plus direct | Des règles recopiées, des tests lents | Plusieurs entrées utilisent les mêmes règles |
| Couches | Une règle à un seul endroit, des contrôleurs fins | Presque rien | Rarement : c'est le bon point de départ |
| Inversion d'une dépendance | Un métier testable sans base, un outil remplaçable | Une interface par dépendance | La dépendance ne gêne pas |
| Hexagonale ou Clean | Un métier isolé, rapide à tester, plusieurs entrées | Une dizaine de fichiers par cas d'usage, une structure à apprendre | Un CRUD, un prototype, une petite équipe pressée |
| DDD (contextes) | Un modèle fidèle au métier, des équipes qui ne se gênent plus | Du temps avec les experts, de la modélisation | Un métier simple |
| CQRS complet | Des lectures rapides, des écritures en arrière-plan | Deux chemins à maintenir, parfois des données en retard | Lire, c'est afficher l'entité |
Une architecture ne se choisit pas une fois pour toutes au premier jour. Elle se fait évoluer, une étape à la fois, quand la douleur apparaît :
- Commence par des couches : des contrôleurs fins, des services, Doctrine.
- Mets les règles dans les entités et dans des objets-valeurs (fiche 6 et section 6).
- Quand un service devient pénible à tester, ou qu'un outil doit pouvoir changer, extrais un port pour cette dépendance-là, et seulement elle.
- Quand plusieurs entrées appellent les mêmes règles, transforme les services en cas d'usage explicites, comme
PlaceOrder. - Quand les écrans de lecture se battent avec les entités, ajoute des classes de requête.
- Quand des équipes se marchent dessus dans les mêmes classes, découpe en contextes.
Et si tu rejoins une équipe qui a déjà fait son choix, suis-le : un projet cohérent vaut mieux qu'un mélange de styles, même si chacun est « meilleur » sur le papier. Pour proposer un changement, écris le problème qu'il résout et ce qu'il coûte, et décidez-en ensemble.
C'est un autre sujet : découper le déploiement, pas seulement le code. Un microservice, c'est en gros un contexte délimité qui tourne à part, avec sa propre base. Il ajoute le réseau entre les morceaux, des pannes partielles, des données à synchroniser et beaucoup plus de déploiements à surveiller (fiches 12 et 13). Le conseil le plus répandu : d'abord un monolithe bien rangé en modules, puis on sort un module en service seulement quand une raison concrète l'exige (une équipe autonome, une charge très différente).
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Une architecture hexagonale pour un CRUD | Dix fichiers pour un formulaire, une équipe ralentie | Des couches simples, et évoluer quand le besoin arrive |
| Une interface pour chaque classe, « au cas où » | Une navigation pénible, du code qui ne sert à rien | Un port seulement pour une dépendance qui gêne vraiment |
Un port qui parle la langue de l'outil (persist, flush, QueryBuilder) | Le cœur dépend encore de Doctrine, sous un autre nom | Des méthodes dans les mots du métier : save, ofCustomer |
| Les règles dans le contrôleur | Recopiées dans la console, puis oubliées | Dans l'entité ou le cas d'usage |
| Le contrôle d'accès seulement sur la route | Une autre entrée le contourne | Le vérifier aussi dans le cas d'usage |
Des entités avec seulement des getters et setters, rangées dans Domain/ | Le vocabulaire du DDD, sans les règles | Des méthodes métier et des objets-valeurs |
Un seul modèle Product pour tout le monde | Une classe énorme que chaque équipe casse | Des contextes délimités, quand la taille le justifie |
| Une requête de lecture qui prend l'identifiant du client dans l'URL | N'importe qui lit les données des autres | L'utilisateur de la session, et seulement les colonnes utiles |
| CQRS avec deux bases dès le départ | Des données en retard et une synchronisation à maintenir | Une seule base, et séparer d'abord les classes |
| Changer d'architecture en cours de route, seul | Deux styles mélangés dans le même projet | Une décision d'équipe, écrite, appliquée progressivement |
Checklist avant de proposer une architecture
- Je sais nommer le problème concret que cette structure résout dans ce projet.
- Chaque règle métier n'existe qu'à un seul endroit.
- Les contrôleurs ne font que traduire HTTP.
- Le code que je veux tester sans base ne cite ni Symfony ni Doctrine.
- Chaque interface a une raison d'exister : une seconde implémentation, ou un test sans la technique.
- Le contrôle d'accès est vérifié quel que soit le point d'entrée.
- Les requêtes de lecture filtrent par l'utilisateur connecté et ne renvoient que les colonnes utiles.
- L'équipe connaît ce choix et l'accepte.
Exercice : sortir les règles d'une fonction
Le fichier exemples/16/exercice.php gère les réservations d'un atelier limité à trois places. Tout est dans une seule fonction : la lecture de la requête, les règles, le SQL, l'e-mail et l'heure actuelle. Aujourd'hui, il affiche :
[e-mail à alice@example.com] place réservée le 07/01/2030 à 10:00 201 : réservé [e-mail à bob@example.com] place réservée le 07/01/2030 à 10:00 201 : réservé [e-mail à chloe@example.com] place réservée le 07/01/2030 à 10:00 201 : réservé 409 : atelier complet 400 : e-mail invalide 422 : ce créneau est déjà passé
- Lis
bookSlot()et classe chaque ligne : traduction de la requête, règle métier, ou technique (SQL, e-mail, horloge). - Crée un cas d'usage
BookSlotqui reçoit trois ports :Bookings(compter et enregistrer les réservations),BookingConfirmation, etClock, avec une méthodenow(). - Écris les adaptateurs PDO et e-mail à partir du code existant, et une horloge réelle. La sortie doit rester exactement la même.
- Écris des adaptateurs en mémoire et une horloge fixe, puis vérifie les quatre règles sans base de données : e-mail invalide, créneau passé, atelier complet, réservation acceptée. Le script actuel réserve un créneau en 2030 : que se passera-t-il en 2030, et comment l'horloge fixe règle-t-elle ce problème ?
- Ajoute une seconde entrée, une « commande console » qui réserve une place pour un e-mail donné, sans recopier aucune règle.
- Question de recul : combien de fichiers as-tu maintenant ? Pour le club de théâtre d'un village, cette architecture valait-elle le coup ? Et pour un organisme de formation qui a un site, une application mobile et un back-office ?
- Bonus : deux personnes qui réservent la dernière place au même instant peuvent toutes les deux réussir. Dans quelle classe de ton architecture places-tu la parade, et laquelle (fiche 7) ?
docker compose run --rm php php exemples/16/exercice.php
Conseils
- Lis les
use. En haut de chaque fichier, ils disent de quoi la classe dépend. Demande-toi pour chacun : « cette classe a-t-elle le droit de connaître celle-là ? » C'est toute l'architecture, ramenée à une question. - Une architecture se mérite. Commence simple, et ajoute de la structure quand une douleur précise apparaît. Elle sera taillée pour un vrai besoin.
- Les noms comptent plus que les dossiers.
PlaceOrderdit ce que fait le code.OrderManagerouOrderHelperne disent rien, quelle que soit l'architecture. - Lis les textes d'origine. L'article d'Alistair Cockburn sur l'architecture hexagonale et celui de Robert C. Martin sur la Clean Architecture sont courts, et plus nuancés que beaucoup de résumés.
- Tu n'as pas à tout maîtriser. Comprendre ce vocabulaire suffit pour suivre une discussion d'équipe et poser les bonnes questions. La pratique viendra sur un vrai projet, une étape à la fois.
Documentation officielle
- Les bonnes pratiques de Symfony, dont les contrôleurs fins
- L'autowiring et les interfaces
- Le composant Messenger et les bus multiples (commandes et requêtes)
- Le composant Clock, une horloge qu'on peut figer dans les tests
- Le composant Uid, pour créer les identifiants avant d'enregistrer
- Les embeddables de Doctrine, pour enregistrer un objet-valeur
- Lire des données avec DBAL, pour les classes de requête
Ces architectures n'ont pas de documentation officielle : voici les textes de leurs auteurs.
- Alistair Cockburn, l'architecture hexagonale (en anglais)
- Robert C. Martin, la Clean Architecture (en anglais)
- Martin Fowler, CQRS et les contextes délimités (en anglais). Le livre d'Eric Evans, Domain-Driven Design (2003), reste la référence du DDD.