SymfonyArchitectureDDD

Construire une architecture Symfony clean pour une application métier

Comment structurer une application métier Symfony pour qu'elle reste lisible, testable et modifiable après des années d'évolutions : séparation domaine et infrastructure, cas d'usage explicites, entités riches et pièges à éviter.

Une application métier Symfony ne meurt pas d'un coup. Elle se dégrade. Au départ, un contrôleur qui manipule Doctrine directement paraît pragmatique. Six mois plus tard, la même règle de calcul existe en trois exemplaires, une modification de tarification casse la génération des factures et plus personne n'ose toucher au module de commandes. Le framework n'y est pour rien. Symfony fournit d'excellents outils, mais il ne décide pas à votre place où vivent les règles de votre métier.

Ce guide décrit une architecture qui tient dans le temps pour une application métier sur-mesure : les principes, la structure concrète des dossiers, la manière d'écrire un cas d'usage, et les erreurs qui ramènent inévitablement au chaos initial.

Le vrai problème : le métier dispersé partout

Le symptôme d'une application mal structurée est toujours le même : pour comprendre comment une commande est validée, il faut lire un contrôleur, deux listeners Doctrine, un service de trois cents lignes, un formulaire et une méthode dans un repository. La règle existe, mais elle n'a pas d'adresse.

Cette dispersion a trois conséquences directes. Les tests deviennent impossibles sans base de données ni conteneur complet, donc on en écrit peu. Chaque modification exige de comprendre toute la chaîne, donc chaque modification coûte cher. Et la reprise par un nouveau développeur prend des semaines au lieu de quelques jours.

Une architecture propre n'a qu'un objectif : donner une adresse unique à chaque règle métier, et rendre cette adresse indépendante de la base de données, du framework HTTP et de l'interface.

Le principe central : le domaine ne connaît pas l'infrastructure

Tout repose sur une séparation en couches dont les dépendances vont dans un seul sens.

Le domaine contient les entités, les objets-valeurs, les règles et les interfaces qui décrivent ce dont le métier a besoin. Il ne dépend de rien : ni de Doctrine, ni de Symfony, ni d'une API externe. Une entité de domaine sait qu'une commande ne peut pas être expédiée si elle n'est pas payée. Elle ne sait pas comment elle est stockée.

La couche application contient les cas d'usage. Chaque action que le système peut réaliser, valider une commande, affecter un technicien, clôturer un dossier, y a sa classe dédiée. Un cas d'usage orchestre le domaine et s'appuie sur des interfaces pour tout ce qui touche à l'extérieur : persistance, envoi d'e-mails, appel d'un service tiers.

L'infrastructure implémente ces interfaces avec les outils concrets : repositories Doctrine, client HTTP, mailer Symfony. C'est la seule couche qui connaît la technologie.

L'interface regroupe ce qui déclenche les cas d'usage : contrôleurs HTTP, commandes console, consommateurs de messages. Un contrôleur reçoit une requête, la transforme en commande applicative, appelle le cas d'usage et formate la réponse. Rien de plus.

La règle qui rend tout cela cohérent tient en une phrase : les dépendances pointent toujours vers le domaine, jamais dans l'autre sens. Le domaine ne peut pas importer Doctrine. L'application ne peut pas importer un contrôleur. Symfony, avec son injection de dépendances et son autowiring sur interfaces, rend cette contrainte facile à respecter, à condition de la vouloir.

La structure concrète d'un projet

Traduite en dossiers, cette organisation ressemble à ceci pour un module de gestion de commandes.

src/
  Order/
    Domain/
      Model/
        Order.php
        OrderLine.php
        OrderStatus.php
      Repository/
        OrderRepositoryInterface.php
      Exception/
        OrderCannotBeShippedException.php
    Application/
      Command/
        ValidateOrder.php
        ValidateOrderHandler.php
      Query/
        FindOrdersToShip.php
        FindOrdersToShipHandler.php
    Infrastructure/
      Doctrine/
        DoctrineOrderRepository.php
        Mapping/Order.orm.xml
      Notification/
        SymfonyMailerOrderNotifier.php
    Presentation/
      Http/
        ValidateOrderController.php
      Console/
        ShipPendingOrdersCommand.php

Deux choix méritent une explication. D'abord, le découpage se fait par module métier, puis par couche, et non l'inverse. Un dossier Controller global avec quarante contrôleurs ne dit rien sur le métier. Un dossier Order dit immédiatement ce que le système fait. Ensuite, le mapping Doctrine est en XML ou en fichiers séparés plutôt qu'en attributs sur les entités, précisément pour que le domaine ne dépende pas de l'ORM. Ce point est débattu, et les attributs restent acceptables sur un projet modeste. Sur une application qui vivra longtemps, le mapping externe préserve la pureté du domaine.

Écrire un cas d'usage explicite

Le cœur de cette architecture est le cas d'usage. Prenons la validation d'une commande.

La commande applicative est un simple objet immuable qui porte les données de l'intention : l'identifiant de la commande et celui de l'utilisateur qui valide. Le handler reçoit cette commande, charge l'agrégat via l'interface du repository, appelle la méthode métier de l'entité, persiste et déclenche les effets secondaires via des interfaces.

final class ValidateOrderHandler
{
    public function __construct(
        private OrderRepositoryInterface $orders,
        private StockCheckerInterface $stock,
        private OrderNotifierInterface $notifier,
    ) {}

    public function __invoke(ValidateOrder $command): void
    {
        $order = $this->orders->get($command->orderId);

        if (!$this->stock->isAvailableFor($order)) {
            throw new InsufficientStockException($order->id());
        }

        $order->validate($command->validatedBy);

        $this->orders->save($order);
        $this->notifier->orderValidated($order);
    }
}

Ce qui compte dans cet exemple, c'est ce qui n'y est pas. Aucune référence à une requête HTTP, à un formulaire ou à l'EntityManager. La règle « une commande ne peut être validée que si elle est en brouillon » vit dans la méthode validate de l'entité, qui lève une exception de domaine si l'état ne le permet pas. Le handler se teste avec des implémentations en mémoire des trois interfaces, en quelques millisecondes, sans base de données.

Le contrôleur HTTP se réduit alors à trois lignes utiles : construire la commande depuis la requête, la passer au handler, retourner une réponse. Cette approche fonctionne aussi bien avec un bus de commandes comme Symfony Messenger qu'avec une injection directe du handler. Le bus apporte le traitement asynchrone et les middlewares, mais il n'est pas indispensable pour bénéficier de la structure.

Des entités riches, pas des sacs de propriétés

L'erreur la plus répandue dans les projets Symfony est le modèle anémique : des entités réduites à des getters et setters, et toute la logique dans des services. C'est l'inverse de ce que l'orienté objet promet, et cela produit exactement la dispersion décrite plus haut.

Une entité de domaine expose des comportements nommés d'après le métier. On n'écrit pas setStatus(OrderStatus::VALIDATED), on écrit validate(), et c'est cette méthode qui vérifie que la transition est légale, met à jour l'état, enregistre la date et l'auteur. L'invariant est garanti à un seul endroit, quel que soit le chemin par lequel on arrive à l'entité.

Les objets-valeurs complètent ce modèle. Un montant, une adresse, une référence de commande ne sont pas des chaînes ou des flottants nus. Un objet Money qui refuse les montants négatifs et impose une devise élimine une catégorie entière de bugs avant même l'écriture d'un test. Dans un ERP ou un CRM sur-mesure, où les règles de calcul sont nombreuses et sensibles, cette rigueur sur les types est ce qui distingue une base fiable d'une base où l'on découvre les erreurs en production.

Séparer lecture et écriture quand cela devient utile

Les cas d'usage qui modifient l'état et ceux qui lisent des données n'ont pas les mêmes contraintes. L'écriture passe par le domaine et ses invariants. La lecture, elle, a surtout besoin d'être rapide et adaptée à l'écran qui l'affiche.

Faire passer un tableau de bord par les agrégats de domaine, avec leurs relations chargées et leurs règles vérifiées, est un gaspillage. Une requête de lecture peut s'appuyer directement sur une requête SQL ou DQL optimisée qui renvoie un DTO taillé pour l'affichage. Le domaine reste protégé pour les écritures, les lectures restent efficaces.

Cette séparation n'implique pas deux bases de données ni une infrastructure d'événements. Elle se résume à distinguer, dans la couche application, les commandes qui modifient et les requêtes qui lisent, et à laisser les secondes contourner le modèle riche quand c'est justifié. C'est aussi ce qui simplifie la conception de l'API : les endpoints d'écriture appellent des handlers de commande, les endpoints de lecture appellent des handlers de requête, et le contrat public ne dépend jamais du schéma de base.

Les pièges qui ramènent au chaos

Une bonne structure de départ ne suffit pas. Quelques dérives classiques la défont en quelques mois.

Les listeners Doctrine comme lieu de logique métier. Déclencher une facturation depuis un événement postPersist rend la règle invisible et impossible à tester isolément. Les effets secondaires appartiennent au handler du cas d'usage, ou à des événements de domaine explicites.

Les entités exposées directement dans les formulaires et les API. Lier un formulaire Symfony à l'entité de domaine contourne ses méthodes métier et ses invariants. Le formulaire doit produire un DTO, que le handler transforme en appels au domaine.

Les services fourre-tout. Un OrderService de huit cents lignes est un signe que les cas d'usage n'ont pas été identifiés. Un handler par intention, avec un nom qui dit ce qu'il fait, reste lisible même quand il y en a cent.

L'abstraction prématurée. À l'inverse, créer une interface pour chaque classe et un événement pour chaque changement d'état sur un module de vingt règles est de la complexité sans contrepartie. L'architecture décrite ici se déploie progressivement, en commençant par les modules où le métier est réellement dense.

L'absence de tests du domaine. Cette architecture n'a de valeur que si le domaine est testé unitairement, sans conteneur ni base. Si les tests du projet sont uniquement des tests fonctionnels HTTP, le bénéfice principal est perdu.

Par où commencer sur une base existante

Peu de projets démarrent de zéro. La question la plus fréquente est de savoir comment appliquer ces principes à une application Symfony qui tourne depuis des années et où tout est entremêlé.

La méthode qui fonctionne est chirurgicale. On identifie le module qui coûte le plus cher en régressions, souvent celui qui touche à l'argent ou aux stocks. On y extrait un premier cas d'usage : on crée le handler, on y déplace la logique dispersée, on écrit les tests unitaires, on fait pointer le contrôleur existant vers le handler. Le reste de l'application ne change pas. On répète, cas d'usage après cas d'usage, jusqu'à ce que le module soit propre. Puis on passe au suivant.

Cette approche préserve la production pendant toute la transition et donne des résultats mesurables dès les premières semaines. Elle suppose en amont un audit technique qui cartographie les zones à risque et fixe l'ordre de traitement. C'est le socle d'une refonte progressive plutôt que d'une réécriture complète, qui reste dans la plupart des cas le pire choix possible pour une application métier en exploitation.

Ce que cette architecture change concrètement

Une application Symfony structurée de cette façon présente des propriétés que les équipes remarquent vite. Une règle métier se trouve en quelques secondes, parce qu'elle a une adresse unique. Un nouveau développeur comprend un module en lisant la liste de ses cas d'usage. Une modification de tarification se teste en isolation, sans lancer le conteneur. Un changement d'outil d'infrastructure, mailer, moteur de recherche, service de paiement, se fait en remplaçant une implémentation sans toucher au métier.

Ce n'est pas de la théorie. C'est la différence entre une application qui coûte de plus en plus cher à faire évoluer et une application qui reste un actif. Si votre application métier Symfony est arrivée au point où chaque évolution fait peur, ou si vous en démarrez une et voulez éviter d'y arriver, un développeur Symfony senior peut poser cette structure avec votre équipe. Décrivez-nous votre contexte : nous vous dirons par quel module commencer et ce que cela représente en effort réel.

Ce guide concerne votre projet ?

Discutons de votre situation concrète. Cadrage gratuit de 30 minutes.