← Toutes les fiches

Fiche 16 Parcours : prendre du recul Lecture : 30 minutes

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.

exemples/16/controller_all_in_one.php · illustration, non exécuté
<?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 :

  1. 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.
  2. 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).
  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.

L'architecture en couches Trois couches empilées au-dessus de la base de données. La présentation (contrôleur, commande console) appelle l'application (OrderService, qui porte les règles), qui appelle l'accès aux données (repository Doctrine), qui parle à la base. Chaque couche n'appelle que celle du dessous. Limite : la couche application dépend directement de Doctrine, et ne se teste pas sans base de données. Présentation OrderController, commande console Traduit la requête HTTP en appel PHP, puis le résultat en réponse. Aucune règle. Application OrderService : le cas d'usage Porte les règles et l'ordre des étapes : vérifier le stock, enregistrer, prévenir. Accès aux données ProductRepository, Doctrine Parle à la base de données. Le SQL ne sort pas de cette couche. Base de données PostgreSQL, MySQL… dépend de Doctrine Limite : le métier dépend de la technique, il ne se teste pas sans base de données. Règle : chaque couche n'appelle que celle du dessous, et ne connaît jamais celle du dessus.
Schéma 16.1 · L'architecture en couches
exemples/16/layered.php · illustration, non exécuté
<?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);
    }
}
  1. La présentation (contrôleurs, commandes console, Twig) traduit le monde extérieur en appels PHP. Le contrôleur ne fait plus que ça.
  2. 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.
  3. 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.

Quand l'utiliser, quand s'en passer

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.

Sécurité : le contrôle d'accès suit le cas d'usage

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 ?

  1. 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.
  2. La technique fournit une classe qui implémente ce port, rangée à l'extérieur du cœur : un adaptateur.
  3. 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.
exemples/16/port_adapter.php · illustration, non exécuté
<?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();
    }
}
L'inversion de dépendances : la flèche change de sens Avant : le cas d'usage PlaceOrder, dans le cœur métier, dépend directement de la classe DoctrineOrderRepository, dans l'infrastructure ; la flèche de dépendance descend. Après : le cœur définit l'interface OrderRepository, que PlaceOrder utilise, et DoctrineOrderRepository implémente cette interface ; la flèche de dépendance remonte de l'infrastructure vers le cœur. Avant : le métier dépend de la technique Après : la technique dépend du métier Cœur : le métier Infrastructure : la technique Cœur : le métier Infrastructure : la technique cas d'usage PlaceOrder classe concrète DoctrineOrderRepository cas d'usage PlaceOrder «interface» OrderRepository implémente l'interface DoctrineOrderRepository La flèche descend : le métier cite Doctrine, et ne se teste pas sans base de données. La flèche remonte : le cœur ne connaît que son interface, et Doctrine vient s'y brancher.
Schéma 16.2 · L'inversion de dépendances : la flèche change de sens

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.

Quand l'utiliser, quand s'en passer

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.

  1. Les adaptateurs d'entrée appellent l'application : un contrôleur HTTP, une commande console, un consommateur de messages, un test.
  2. 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 :

exemples/16/hexagonal.php
<?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";
Sortie
  [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
L'architecture hexagonale : ports et adaptateurs Au centre, un hexagone : l'application, avec le cas d'usage PlaceOrder et le domaine (Order, OutOfStock). À gauche, les adaptateurs d'entrée (contrôleur HTTP, commande console, test) appellent le cas d'usage. Sur le bord droit, les ports de sortie, des interfaces : Stock, OrderRepository, OrderConfirmation. À droite, les adaptateurs de sortie (SQL, Doctrine, Symfony Mailer) implémentent ces ports. Toutes les flèches de dépendance pointent vers l'intérieur. Application cas d'usage PlaceOrder Domaine Order, OutOfStock Ports de sortie Stock OrderRepository OrderConfirmation Adaptateurs de sortie SQL (DBAL) Doctrine ORM Symfony Mailer En test : les mêmes ports, en mémoire. Adaptateurs d'entrée Contrôleur HTTP Commande console Test Une flèche signifie « dépend de ». Toutes pointent vers l'intérieur : l'application ne connaît aucun adaptateur, et un adaptateur se remplace sans la toucher.
Schéma 16.3 · L'architecture hexagonale : ports et adaptateurs

Deux remarques honnêtes sur cet exemple :

  1. 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èvera OutOfStock si aucune ligne n'a changé.
  2. 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.
Quand l'utiliser, quand s'en passer

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.

La Clean Architecture : des cercles, et une seule direction Quatre cercles concentriques. Au centre, les entités (Order, Money). Autour, les cas d'usage (PlaceOrder et ses ports). Puis les adaptateurs (OrderController, DoctrineOrderRepository). À l'extérieur, les frameworks et pilotes (Symfony, Doctrine ORM, PostgreSQL, serveur SMTP). Les dépendances pointent toutes de l'extérieur vers le centre. Frameworks et pilotes Adaptateurs Cas d'usage Entités dépendances Entités Order, Money : les règles les plus stables du métier Cas d'usage PlaceOrder et ses ports Adaptateurs OrderController, DoctrineOrderRepository Frameworks et pilotes Symfony, Doctrine ORM, PostgreSQL, serveur SMTP Règle de dépendance : le code d'un cercle ne cite jamais le nom d'une classe d'un cercle plus extérieur. Le métier, au centre, ignore Symfony et Doctrine.
Schéma 16.4 · La Clean Architecture : des cercles, et une seule direction

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 :

NomVocabulaireCe qu'elles partagent
Hexagonale (2005)application, ports, adaptateurs d'entrée et de sortieLe 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.

Quand l'utiliser, quand s'en passer

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.

  1. 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).
  2. 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.
  3. Des briques pour modéliser : entités, objets-valeurs, agrégats, détaillées plus bas.
Le DDD : un même mot, un modèle par contexte Trois contextes délimités ont chacun leur propre classe Produit. Catalogue : nom, description, photos, prix affiché, dans le langage du marketing. Logistique : poids, dimensions, emplacement, stock, dans le langage de l'entrepôt. Facturation : prix hors taxes, taux de TVA, compte comptable, dans le langage de la comptabilité. Les trois ne partagent que la référence du produit. Contexte Catalogue Produit référence nom description photos prix affiché Équipe : marketing Contexte Logistique Produit référence poids dimensions emplacement stock Équipe : entrepôt Contexte Facturation Produit référence prix hors taxes taux de TVA compte comptable Équipe : comptabilité Le même mot, trois modèles. Un seul Produit qui contiendrait tout serait énorme, et chaque équipe le casserait en le modifiant. Les contextes ne partagent que la référence (en pointillés).
Schéma 16.5 · Le DDD : un même mot, un modèle par contexte

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 :

  1. 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.
  2. 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.
exemples/16/aggregate.php
<?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";
Sortie
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.

Quand l'utiliser, quand s'en passer

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 :

  1. Une commande modifie l'état, passe par les règles du métier, et ne renvoie rien.
  2. Une requête (query) lit des données, ne modifie rien, et ne passe par aucune règle.
CQRS : un chemin pour écrire, un autre pour lire En haut, l'écriture : la requête POST /orders crée un message PlaceOrderCommand, envoyé par le bus à PlaceOrderHandler, qui vérifie les règles avec l'agrégat Order et enregistre en base. Elle ne renvoie rien. En bas, la lecture : la requête GET /orders appelle CustomerOrdersQuery, une requête SQL taillée pour l'écran, qui lit la même base sans passer par les entités ni les règles. Écrire : passer par les règles Lire : aller droit aux données POST /orders contrôleur PlaceOrderCommand un message, via le bus PlaceOrderHandler règles, agrégat Order Vérifie les règles, modifie l'état, ne renvoie rien. Base de données une seule, au départ GET /orders contrôleur CustomerOrdersQuery un SELECT taillé pour l'écran, sans entités Ne modifie rien, ne vérifie aucune règle métier. Les deux côtés évoluent séparément. Une base dédiée à la lecture ne vient que si les mesures le demandent, et elle a un prix : les données lues peuvent avoir un léger retard.
Schéma 16.6 · CQRS : un chemin pour écrire, un autre pour lire

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 :

exemples/16/cqrs.php · illustration, non exécuté
<?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()));
    }
}
  1. 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.
  2. 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.
  3. 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.
  4. 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.
Quand l'utiliser, quand s'en passer

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.

Sécurité : le côté lecture contourne tes protections

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.

ArchitectureCe qu'elle rapporteCe qu'elle coûteÀ éviter quand
Tout dans le contrôleurLe code le plus court et le plus directDes règles recopiées, des tests lentsPlusieurs entrées utilisent les mêmes règles
CouchesUne règle à un seul endroit, des contrôleurs finsPresque rienRarement : c'est le bon point de départ
Inversion d'une dépendanceUn métier testable sans base, un outil remplaçableUne interface par dépendanceLa dépendance ne gêne pas
Hexagonale ou CleanUn métier isolé, rapide à tester, plusieurs entréesUne dizaine de fichiers par cas d'usage, une structure à apprendreUn 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 plusDu temps avec les experts, de la modélisationUn métier simple
CQRS completDes lectures rapides, des écritures en arrière-planDeux chemins à maintenir, parfois des données en retardLire, c'est afficher l'entité
Chaque architecture coûte plus de structure que la précédente Un escalier de quatre marches. Tout dans le contrôleur : un fichier, pour un prototype ou un CRUD simple. Couches : trois fichiers, pour la plupart des applications. Hexagonale ou Clean : une dizaine de fichiers, pour un métier riche avec plusieurs points d'entrée. DDD et CQRS : en plus un bus, des messages et des requêtes, pour un métier complexe et plusieurs équipes. Plus on monte, plus la structure à écrire et à maintenir augmente. structure à écrire et à maintenir → Tout dans le contrôleur 1 fichier prototype, CRUD simple Couches 3 fichiers la plupart des applications Hexagonale ou Clean ≈ 10 fichiers métier riche, plusieurs entrées DDD et CQRS + bus, messages, requêtes métier complexe, plusieurs équipes complexité du métier, nombre de points d'entrée, durée de vie du projet → Fichiers : ordre de grandeur pour l'exemple « passer une commande » de cette fiche. Monter d'une marche n'est rentable que si le projet est vraiment arrivé à ce niveau de complexité.
Schéma 16.7 · Chaque architecture coûte plus de structure que la précédente

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 :

  1. Commence par des couches : des contrôleurs fins, des services, Doctrine.
  2. Mets les règles dans les entités et dans des objets-valeurs (fiche 6 et section 6).
  3. 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.
  4. Quand plusieurs entrées appellent les mêmes règles, transforme les services en cas d'usage explicites, comme PlaceOrder.
  5. Quand les écrans de lecture se battent avec les entités, ajoute des classes de requête.
  6. 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.

Et les microservices ?

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ègeCe qui arriveLa parade
Une architecture hexagonale pour un CRUDDix fichiers pour un formulaire, une équipe ralentieDes 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 à rienUn 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 nomDes méthodes dans les mots du métier : save, ofCustomer
Les règles dans le contrôleurRecopiées dans la console, puis oubliéesDans l'entité ou le cas d'usage
Le contrôle d'accès seulement sur la routeUne autre entrée le contourneLe 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èglesDes méthodes métier et des objets-valeurs
Un seul modèle Product pour tout le mondeUne classe énorme que chaque équipe casseDes contextes délimités, quand la taille le justifie
Une requête de lecture qui prend l'identifiant du client dans l'URLN'importe qui lit les données des autresL'utilisateur de la session, et seulement les colonnes utiles
CQRS avec deux bases dès le départDes données en retard et une synchronisation à maintenirUne seule base, et séparer d'abord les classes
Changer d'architecture en cours de route, seulDeux styles mélangés dans le même projetUne décision d'équipe, écrite, appliquée progressivement

Checklist avant de proposer une architecture

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 :

Sortie
  [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é
  1. Lis bookSlot() et classe chaque ligne : traduction de la requête, règle métier, ou technique (SQL, e-mail, horloge).
  2. Crée un cas d'usage BookSlot qui reçoit trois ports : Bookings (compter et enregistrer les réservations), BookingConfirmation, et Clock, avec une méthode now().
  3. Écris les adaptateurs PDO et e-mail à partir du code existant, et une horloge réelle. La sortie doit rester exactement la même.
  4. É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 ?
  5. Ajoute une seconde entrée, une « commande console » qui réserve une place pour un e-mail donné, sans recopier aucune règle.
  6. 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 ?
  7. 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

  1. 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.
  2. 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.
  3. Les noms comptent plus que les dossiers. PlaceOrder dit ce que fait le code. OrderManager ou OrderHelper ne disent rien, quelle que soit l'architecture.
  4. 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.
  5. 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

Ces architectures n'ont pas de documentation officielle : voici les textes de leurs auteurs.