Refactoriser une application Symfony avec la Clean Architecture et le DDD

Passer de dossiers techniques à des contextes bornés, diriger les dépendances vers le domaine et vérifier ces règles avec Deptrac.
Dans mon application Symfony, une nouvelle fonctionnalité finissait souvent répartie entre un contrôleur, une entité, un dépôt et un service. Cette organisation par type technique était facile à comprendre au départ. En grandissant, le dossier Service a rassemblé des règles métier sans rapport, les contrôleurs ont commencé à prendre des décisions et un changement de prestataire de paiement touchait plusieurs zones du code.
Le problème s’est posé dans Leganews, une plateforme d’information juridique que j’ai cofondée. Le produit avait déjà des utilisateurs et des données historiques. La refonte devait donc préserver le comportement existant pendant le déplacement du code.
J’ai rendu les frontières métier visibles et isolé le domaine de Symfony et Doctrine. Cette séparation n’a pas supprimé le coût de la migration, mais elle a réduit la portée des changements suivants.
L’implémentation combine Clean Architecture, Domain-Driven Design, injection de dépendances Symfony, Messenger, mappings Doctrine externes et Deptrac. Je détaille la structure et les contrôles qui la rendent vérifiable.
Les limites d’une organisation par type technique
Une application Symfony classique commence souvent par une structure de ce type :
Cette structure n’a rien de mauvais en soi. Symfony fournit des choix par défaut pertinents, souvent suffisants pour une petite application.
Le problème apparaît lorsque le produit couvre plusieurs domaines métier. Une modification de la facturation peut être répartie entre Controller, Entity, Repository et Service. L’arborescence indique quel outil technique une classe utilise, mais pas à quelle capacité métier elle appartient.
À mesure que la plateforme grandissait, le code devait permettre de répondre à des questions plus utiles :
-
Cette partie relève-t-elle de l’identité, de la facturation, du suivi ou du corpus juridique ?
-
Quelles règles relèvent du métier et lesquelles ne sont que des détails propres à Doctrine ou Symfony ?
-
La facturation peut-elle évoluer sans casser accidentellement le suivi ?
-
Un cas d’usage peut-il être testé sans base de données ni requête HTTP ?
C’est à ce stade que le DDD et la Clean Architecture me sont devenus utiles.
La Clean Architecture et le DDD en quelques mots
Le Domain-Driven Design m’aide à organiser le système autour du langage et des frontières du métier. Au lieu d’un grand modèle applicatif unique, je définis des contextes bornés comme Identity, Billing, Access, Corpus et Monitoring.
La Clean Architecture dirige les dépendances du code source vers les règles métier, jamais dans le sens inverse.
Dans mon implémentation, leur direction se présente ainsi :
Le principe est simple :
-
Domaincontient les concepts et les règles métier. -
Applicationcoordonne les cas d’usage. -
Infrastructureimplémente les détails techniques comme Doctrine, Redis et les API externes. -
Presentationtransforme les entrées HTTP ou console en messages applicatifs.
Le domaine ignore l’existence de Symfony, Doctrine, PostgreSQL et Redis.
Organiser le code par contexte borné
Au lieu de regrouper toutes les entités, j’ai placé le contexte borné au premier niveau de l’espace de noms :
src/
├── Access/
├── Billing/
├── Compare/
├── Corpus/
├── Identity/
├── Monitoring/
├── Policy/
└── Shared/Chaque contexte possède ensuite ses propres couches :
src/Monitoring/
├── Application/
│ └── UseCase/
│ ├── Command/
│ ├── CommandHandler/
│ ├── Query/
│ └── QueryHandler/
├── Domain/
│ ├── Event/
│ ├── Model/
│ │ ├── Entity/
│ │ ├── Repository/
│ │ └── ValueObject/
│ └── Service/
├── Infrastructure/
│ ├── Persistence/
│ └── Search/
└── Presentation/
├── Console/
└── Web/Désormais, le code des signets se trouve principalement dans Monitoring. La structure des dossiers exprime le langage du produit avant les détails du framework.
Shared n’accueille pas ce que je ne sais pas classer. Il contient uniquement les concepts communs à plusieurs contextes. Une classe qui emploie le langage du produit appartient probablement à un contexte borné.
Déplacer le Kernel de Symfony dans la couche Infrastructure
Dans un projet Symfony standard, Kernel.php se trouve directement sous src/. Je l’ai déplacé vers :
src/Shared/Infrastructure/Framework/Kernel.phpCe déplacement ne crée pas une Clean Architecture par magie. Il rend une décision visible. Symfony démarre l’application depuis l’infrastructure, mais ne se trouve pas au centre du modèle métier.
Puisque la classe a changé d’emplacement, les points d’entrée HTTP et console importent son nouvel espace de noms.
<?php
use Leganews\Shared\Infrastructure\Framework\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel(
$context['APP_ENV'],
(bool) $context['APP_DEBUG'],
);
};La même importation est utilisée dans bin/console.
Le chargement automatique de Composer reste simple :
{
"autoload": {
"psr-4": {
"Leganews\\": "src/"
}
}
}Le déplacement du Kernel reste facultatif. La règle de dépendance compte davantage. Dans mon cas, ce déplacement l’a rendue visible et plus difficile à oublier.
Relier les couches par l’injection de dépendances
La Clean Architecture n’impose pas d’éviter Symfony. Elle consiste à utiliser Symfony aux extrémités et à dépendre de contrats en allant vers le centre.
Ma configuration principale des services enregistre automatiquement les classes des couches Application, Infrastructure et Presentation :
# config/services.yaml
imports:
- { resource: domains/ }
services:
_defaults:
autowire: true
autoconfigure: true
Leganews\:
resource: '../src/'
exclude:
- '../src/**/Infrastructure/Framework/DependencyInjection/*'
- '../src/**/Domain/Model/Entity/*'
- '../src/**/Domain/Model/ValueObject/*'
- '../src/Shared/Infrastructure/Framework/Kernel.php'Pourquoi exclure les entités et les objets-valeurs ? Parce qu’il s’agit d’objets du domaine, et non de services du conteneur. Ils doivent être créés par le domaine ou l’application à partir de données explicites, plutôt que récupérés dans le conteneur de Symfony.
Lorsque l’autowiring est activé, un handler peut dépendre d’une interface du domaine :
final readonly class CreateBookmarkHandler
{
public function __construct(
private BookmarkRepository $bookmarks,
private Clock $clock,
private EventDispatcher $eventDispatcher,
) {}
}La couche Application ne demande ni EntityManagerInterface ni un dépôt Doctrine. Elle dépend du port métier BookmarkRepository.
Lorsqu’il n’existe qu’une implémentation, Symfony peut la résoudre sans qu’il soit nécessaire d’ajouter des alias répétitifs. Dans mon application, par exemple, le conteneur résout :
Lorsque plusieurs implémentations existent, la configuration en sélectionne une. Ma passerelle de paiement possède un adaptateur en mémoire et un adaptateur FlexPay. L’application reçoit celui choisi pour l’environnement.
# config/domains/billing.yaml
services:
billing.online_payment_gateway.in_memory:
alias: Leganews\Billing\Infrastructure\OnlinePayment\InMemoryOnlinePaymentGateway
billing.online_payment_gateway.flexpay:
alias: Leganews\Billing\Infrastructure\OnlinePayment\FlexPayOnlinePaymentGateway
Leganews\Billing\Domain\Service\OnlinePaymentGateway:
alias: Leganews\Billing\Infrastructure\OnlinePayment\ConfigurableOnlinePaymentGatewayLe cas d’usage possède l’interface. Symfony choisit l’adaptateur technique à la racine de composition.
Conserver les règles métier dans de simples objets PHP
Prenons une opération réelle de la plateforme, la création d’un signet pour une recherche juridique.
L’entité Bookmark est un simple objet PHP. Elle valide son propre état et enregistre un événement de domaine :
final class Bookmark
{
use DomainEvents;
public readonly BookmarkId $id;
private function __construct(
?BookmarkId $id,
public readonly UserId $userId,
public private(set) string $name,
public private(set) ?string $description,
public private(set) BookmarkVisibility $visibility,
public readonly DateTimeImmutable $createdAt,
public private(set) DateTimeImmutable $updatedAt,
) {
$this->id = $id ?? new BookmarkId();
Assert::notEmpty($name);
}
public static function create(
UserId $userId,
string $name,
?string $description,
BookmarkVisibility $visibility,
DateTimeImmutable $now,
): self {
$bookmark = new self(
id: null,
userId: $userId,
name: $name,
description: $description,
visibility: $visibility,
createdAt: $now,
updatedAt: $now,
);
$bookmark->recordThat(
new BookmarkCreated($bookmark->id, $userId, $now),
);
return $bookmark;
}
}Ce code ne contient ni contrôleur, ni requête HTTP, ni gestionnaire d’entités, ni attribut Doctrine.
Je transmets également l’heure actuelle au domaine. L’entité n’appelle pas new DateTimeImmutable() pour déterminer l’instant présent. Le temps devient ainsi explicite et contrôlable dans les tests.
Définir la persistance comme un port du domaine
Le domaine déclare les opérations de persistance dont il a besoin :
interface BookmarkRepository
{
public function save(Bookmark $bookmark): void;
public function remove(Bookmark $bookmark): void;
public function get(BookmarkId $id): Bookmark;
/** @return list<Bookmark> */
public function findByUser(UserId $userId): array;
}Cette interface n’est pas une abstraction ajoutée "au cas où". Un cas d’usage réel exige ce port. Le domaine possède son langage et l’Infrastructure fournit l’implémentation.
final class DoctrineBookmarkRepository extends ServiceEntityRepository
implements BookmarkRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bookmark::class);
}
public function save(Bookmark $bookmark): void
{
$this->getEntityManager()->persist($bookmark);
}
public function get(BookmarkId $id): Bookmark
{
$bookmark = $this->find($id);
if (!$bookmark instanceof Bookmark) {
throw BookmarkNotFound::withId($id);
}
return $bookmark;
}
}Doctrine dépend de la classe du domaine. La classe du domaine ne dépend pas de Doctrine. C’est cette direction qui importe.
Elle m’offre également un point de substitution utile pour les tests. Un test peut remplacer Doctrine par un petit dépôt en mémoire qui implémente la même interface.
Maintenir le mapping Doctrine hors du domaine
Les attributs Doctrine sont pratiques, mais les placer sur les entités du domaine crée une dépendance directe entre le domaine et l’ORM.
J’ai choisi des mappings XML externes :
<!-- config/doctrine/Monitoring/Entity.Bookmark.orm.xml -->
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
>
<entity
name="Leganews\Monitoring\Domain\Model\Entity\Bookmark"
repository-class="Leganews\Monitoring\Infrastructure\Persistence\ORM\DoctrineBookmarkRepository"
table="bookmarks"
>
<id name="id" type="bookmark_id" column="id">
<generator strategy="NONE"/>
</id>
<field name="userId" type="user_id" column="user_id"/>
<field name="name" length="120"/>
<field name="description" type="text" nullable="true"/>
<field name="visibility" enum-type="Leganews\Monitoring\Domain\Model\ValueObject\BookmarkVisibility"/>
<field name="createdAt" type="datetime_immutable" column="created_at"/>
<field name="updatedAt" type="datetime_immutable" column="updated_at"/>
</entity>
</doctrine-mapping>Chaque contexte borné reçoit ensuite sa propre configuration de mapping Doctrine :
# config/packages/doctrine.yaml
doctrine:
orm:
entity_managers:
default:
mappings:
Monitoring:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Monitoring'
prefix: 'Leganews\Monitoring\Domain\Model'
Billing:
is_bundle: false
type: xml
dir: '%kernel.project_dir%/config/doctrine/Billing'
prefix: 'Leganews\Billing\Domain\Model'Répétez le bloc de mapping pour les autres contextes.
Cette configuration est légèrement plus verbeuse que les attributs, mais elle maintient les métadonnées de persistance à la périphérie. Je peux lire et tester unitairement l’entité comme un objet PHP ordinaire.
Utiliser les commandes et les requêtes comme frontières de l’application
La couche Application coordonne un cas d’usage. Elle ne contient pas de code HTTP et ne doit pas réimplémenter des règles déjà prises en charge par le domaine.
Je définis d’abord la commande :
final readonly class CreateBookmark
{
public function __construct(
public UserId $userId,
public string $name,
public ?string $description,
public BookmarkVisibility $visibility,
) {}
}Le handler coordonne ensuite le domaine, le dépôt, l’horloge et les événements :
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateBookmarkHandler
{
public function __construct(
private BookmarkRepository $bookmarks,
private Clock $clock,
private EventDispatcher $eventDispatcher,
) {}
public function __invoke(CreateBookmark $command): BookmarkId
{
$bookmark = Bookmark::create(
userId: $command->userId,
name: $command->name,
description: $command->description,
visibility: $command->visibility,
now: $this->clock->now(),
);
$this->bookmarks->save($bookmark);
$this->eventDispatcher->dispatch($bookmark->releaseEvents());
return $bookmark->id;
}
}Enfin, le contrôleur transforme l’entrée HTTP en commande applicative :
#[Route(
'/monitoring/self-service/bookmarks',
name: 'monitoring.create_bookmark',
methods: ['POST'],
)]
final class CreateBookmarkController extends AbstractController
{
#[Serialize(code: Response::HTTP_CREATED)]
public function __invoke(
#[MapRequestPayload] CreateBookmarkModel $model,
): array {
$bookmarkId = $this->handleCommand(new CreateBookmark(
userId: $this->getSecurityUser()->userId,
name: $model->name,
description: $model->description,
visibility: BookmarkVisibility::from($model->visibility),
));
return ['id' => (string) $bookmarkId];
}
}Le contrôleur est volontairement sans intérêt particulier. L’authentification, le mapping de la requête et la sérialisation de la réponse ont leur place ici. La décision concernant la création d’un signet, elle, n’y appartient pas.
Configurer des bus de messages distincts
J’utilise Symfony Messenger derrière mes propres contrats légers CommandBus, QueryBus, MessageBus et EventDispatcher.
Les adaptateurs Symfony se trouvent dans Infrastructure. Les handlers restent ainsi indépendants de l’API d’exécution de Messenger, tout en pouvant utiliser des attributs pratiques comme #[AsMessageHandler].
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
- Leganews\Shared\Infrastructure\Framework\Bus\TransactionMiddleware
query.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
event.bus:
default_middleware:
allow_no_handlers: true
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddleware
message.bus:
middleware:
- Leganews\Shared\Infrastructure\Framework\Bus\CorrelationMiddlewareLes commandes modifient l’état, donc leur bus ajoute un middleware de transaction Doctrine. Les requêtes lisent l’état sans cette transaction. Le bus d’événements accepte l’absence de gestionnaire, car un fait peut n’avoir aucun abonné.
Le middleware de corrélation transmet un même identifiant tout au long de la chaîne de messages, ce qui facilite le suivi des journaux d’une opération complète.
Cette séparation n’est pas seulement stylistique. Elle donne un objectif clair à chaque message et me permet d’appliquer des politiques de middleware différentes selon les opérations.
Faire respecter l’architecture avec Deptrac
Une convention de dossiers est utile jusqu’au jour où quelqu’un importe accidentellement Doctrine dans le domaine sans que la revue de code ne le détecte.
L’architecture doit être exécutable. J’utilise Deptrac pour décrire les dépendances autorisées :
# deptrac.yaml
deptrac:
paths:
- ./src
layers:
- name: Presentation
collectors:
- type: directory
value: src/.*/Presentation/.*
- name: Infrastructure
collectors:
- type: directory
value: src/.*/Infrastructure/.*
- name: Application
collectors:
- type: directory
value: src/.*/Application/.*
- name: Domain
collectors:
- type: directory
value: src/.*/Domain/.*
- name: SymfonyAttributes
collectors:
- type: classNameRegex
value: '#^Symfony\\Component\\(?:Console|DependencyInjection|EventDispatcher|Messenger|Routing)\\Attribute\\.*#'
- name: SymfonyRuntime
collectors:
- type: classNameRegex
value: '#^Symfony\\(?:Component|Contracts)\\.*#'
ruleset:
Presentation:
- Domain
- Application
- Infrastructure
- SymfonyAttributes
- SymfonyRuntime
Infrastructure:
- Application
- Domain
- SymfonyAttributes
- SymfonyRuntime
Application:
- Domain
- SymfonyAttributes
Domain: ~La ligne importante est Domain: ~. Les classes du domaine ne peuvent dépendre ni d’une autre couche architecturale ni de l’environnement d’exécution de Symfony.
Je modélise volontairement les attributs Symfony à part. Un handler applicatif peut utiliser le marqueur passif #[AsMessageHandler] sans recevoir le bus de messages de Symfony comme dépendance d’exécution.
Exécutez le contrôle avec :
vendor/bin/deptrac --no-progressAu moment de la rédaction, le projet ne signale aucune violation non ignorée. Je conserve également une liste skip_violations visible pour les dépendances acceptées, comme l’implémentation UUID de Symfony.
Cette liste compte. Une violation ignorée ne signifie pas que "l’architecture est parfaite". Elle documente un compromis à réexaminer au lieu de cacher la dépendance dans le code.
Mes règles actuelles imposent la direction globale des couches. Elles ne garantissent pas encore une isolation complète entre tous les contextes bornés. Par exemple, une classe du domaine d’un contexte peut encore référencer un objet-valeur du domaine d’un autre contexte sans que Deptrac ne le refuse.
Si votre système exige une isolation plus forte des contextes, ajoutez une couche par contexte ou utilisez des collecteurs supplémentaires. Commencez par des règles que votre équipe peut comprendre et maintenir, puis renforcez-les à mesure que les frontières se stabilisent.
Refactoriser sans ignorer le système historique
Une contrainte a guidé cette architecture. Leganews possédait déjà des données dans une ancienne application MariaDB, tandis que la plateforme refactorisée utilise PostgreSQL.
Cette réécriture ne pouvait pas se limiter à améliorer la forme du code. Elle devait préserver les utilisateurs, le contenu juridique, les abonnements, les commandes, les signets et leurs relations.
J’ai isolé l’ancienne base de données dans une connexion Doctrine DBAL distincte :
doctrine:
dbal:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
legacy:
url: '%env(resolve:LEGACY_DATABASE_URL)%'J’ai ensuite organisé l’importation sous forme de plans ordonnés, plutôt que dans un unique et énorme script de migration :
parameters:
legacy.plan_order:
- users
- federated_identities
- classifications
- subjects
- works
- work_attachments
- expressions
- products
- prices
- orders
- payment_transactions
- organizations
- subscriptions
- organization_members
- access_grants
- bookmarks
- bookmark_itemsL’ordre représente les dépendances entre les données. Les utilisateurs doivent exister avant leurs signets ; les produits et les prix avant les commandes ; les organisations avant les adhésions.
Chaque plan implémente un contrat de migration au niveau applicatif, tandis que SQL et la connaissance de l’ancien schéma restent dans Infrastructure. L’importateur prend en charge l’orchestration, les rapports, la gestion des conflits et les échecs de lignes.
J’ai également conservé les identifiants stables lorsque cela était possible. Cela a limité les références rompues pendant que le nouveau modèle introduisait des concepts et des frontières plus clairs.
J’ai ainsi obtenu une démarche de refactorisation concrète :
-
Modéliser une capacité métier dans son contexte borné.
-
Placer les nouveaux cas d’usage derrière des commandes et des requêtes applicatives.
-
Adapter la persistance et les systèmes externes à la périphérie d’Infrastructure.
-
Importer les données historiques dans l’ordre de leurs dépendances.
-
Vérifier les décomptes, les conflits et les lignes rejetées avant de basculer le trafic.
La leçon est que la Clean Architecture n’est pas seulement utile après une migration. Elle aide à créer une destination sûre vers laquelle migrer.
Tester les frontières, pas seulement les classes
L’interface du dépôt permet à mes tests applicatifs de s’exécuter avec des adaptateurs en mémoire et une horloge contrôlable.
Un scénario de cas d’usage se présente ainsi :
Scenario: Creating a bookmark starts a private collection
Given current time is "2026-07-13 10:00:00"
When a bookmark is created through the application with the following details:
| userId | name | description | visibility |
| ... | Research folder | | private |
Then the bookmark should keep the following presentation:
| name | visibility | updatedAt |
| Research folder | private | 2026-07-13 10:00:00 |
And the bookmark creation should be announcedCe test utilise le véritable bus de commandes et le véritable handler, mais remplace la base de données par un dépôt en mémoire. Il vérifie la frontière applicative sans transformer chaque scénario métier en un test lent dans le navigateur.
J’utilise différents types de tests selon les risques :
-
PHPUnit pour les entités, les objets-valeurs, les politiques et les règles isolées du domaine.
-
Behat pour les commandes et les requêtes à la frontière applicative.
-
Les tests fonctionnels Symfony pour le routage, la sécurité, la sérialisation, les mappings Doctrine et les autres intégrations au framework.
Deptrac contrôle ensuite un aspect que les tests ne vérifient généralement pas : la validité de la direction des dépendances.
Ajouter le contrôle d’architecture à la commande de qualité
Une règle d’architecture exécutée uniquement sur la machine d’un développeur finira inévitablement par être contournée.
Ma commande de qualité regroupe le formatage, la validation de la configuration Symfony, l’analyse statique, une simulation Rector et Deptrac. Voici la partie liée à cet article :
{
"scripts": {
"app:architecture": "deptrac --no-progress",
"app:test": "phpunit",
"app:behat": "behat"
}
}Exécutez la commande d’architecture dans la CI à chaque modification. Une dépendance interdite doit provoquer un échec comme le ferait un test, et non susciter une discussion six mois plus tard.
Ce que cette architecture ne résout pas
La Clean Architecture implique des compromis.
-
Les fichiers sont plus nombreux, car les commandes, les handlers, les ports et les adaptateurs ont des responsabilités distinctes.
-
Les mappings Doctrine externes sont plus verbeux que les attributs d’entité.
-
L’équipe doit s’accorder sur le langage du domaine, sans quoi les contextes bornés deviennent de simples dossiers techniques mieux nommés.
-
Deptrac protège uniquement les règles configurées. Il ne peut pas déterminer si un concept métier appartient au bon contexte.
-
Toutes les opérations CRUD n’ont pas besoin d’un agrégat complexe ni d’un événement de domaine.
J’essaie de rester pragmatique. L’objectif n’est pas de multiplier les interfaces et les couches, mais de maintenir les décisions métier importantes indépendantes des détails d’exposition et de persistance.
Si un simple modèle de lecture peut répondre à une requête, je l’utilise. S’il n’existe qu’un seul adaptateur, je laisse Symfony le câbler automatiquement. Si j’accepte une exception architecturale, je la consigne explicitement au lieu de prétendre qu’elle n’existe pas.
Ce que cette séparation change
La refactorisation de Leganews autour de la Clean Architecture et du DDD a transformé bien plus que l’arborescence des dossiers.
-
Les contextes bornés ont rendu visibles les responsabilités métier.
-
Les objets du domaine sont devenus de simples objets PHP aux règles explicites.
-
Les contrats des dépôts et des bus ont inversé les dépendances vers le cœur de l’application.
-
Le conteneur d’injection de dépendances de Symfony a assemblé l’implémentation aux extrémités.
-
Les mappings Doctrine externes ont maintenu les détails de l’ORM hors du domaine.
-
Messenger a attribué aux commandes, aux requêtes et aux événements des politiques d’exécution distinctes.
-
Deptrac a transformé l’intention architecturale en contrôle automatisé.
-
Les plans de migration ordonnés ont permis au nouveau modèle de coexister avec les données historiques pendant la refactorisation.
Le résultat n’est pas une application sans framework. C’est une application Symfony dans laquelle Symfony est employé délibérément, là où il est le plus efficace, sans devenir lui-même le modèle métier.
Je peux ainsi faire évoluer le système sans rendre chaque décision technique permanente.
Cette refonte n’a pas consisté à déplacer chaque classe vers un dossier au nom plus savant. Les contextes bornés ont regroupé le code par capacité métier. Les ports ont rendu les dépendances visibles. Les mappings Doctrine externes et Deptrac ont empêché le framework de revenir discrètement dans le domaine.
Le compromis reste réel. Cette structure ajoute des fichiers et demande une discipline d’équipe. Elle se justifie lorsque plusieurs domaines évoluent indépendamment et que le coût du couplage est déjà visible. Pour une petite application stable, l’organisation Symfony habituelle peut rester le meilleur choix.