Comment j’ai refactorisé mon application Symfony avec la Clean Architecture et le DDD

Comment j’ai fait évoluer une plateforme Symfony en croissance, de dossiers techniques vers des contextes bornés, des dépendances dirigées vers le domaine, des ports explicites et des contrôles d’architecture avec Deptrac.
Avez-vous déjà ouvert un projet Symfony dans lequel chaque nouvelle fonctionnalité exigeait de modifier un contrôleur, une entité, un dépôt et un service répartis dans quatre dossiers différents ?
Au début, cette structure paraît simple. Puis l’application grandit. Le dossier Service devient un assemblage de logiques métier sans rapport entre elles, les contrôleurs commencent à prendre des décisions métier et le changement d’un prestataire de paiement ou d’un détail de base de données impose des modifications partout.
J’en ai fait directement l’expérience en construisant Leganews, une plateforme d’information juridique que j’ai cofondée. J’avais un produit réel, des utilisateurs existants et des données historiques qui ne pouvaient pas simplement disparaître pendant la restructuration du code.
Il ne s’agissait donc pas d’un exercice d’architecture sur un projet vierge. J’ai refactorisé le système pour rendre ses frontières métier visibles, protéger le domaine de Symfony et Doctrine, et permettre à la plateforme de continuer à évoluer sans transformer chaque changement en opération risquée.
Dans cet article, je montre comment j’ai combiné la Clean Architecture, le Domain-Driven Design, le conteneur d’injection de dépendances de Symfony, Messenger, des mappings Doctrine externes et Deptrac. Je présente surtout la configuration nécessaire pour reproduire cette approche dans votre propre projet.
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 me donne une règle de dépendance : les dépendances du code source sont dirigées vers les règles métier, et non 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.
Étape 1 : 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, lorsqu’un développeur travaille sur les signets, l’essentiel du code concerné se trouve dans Monitoring. La structure des dossiers exprime le langage du produit avant les détails du framework.
Une règle est importante : Shared ne sert pas à accueillir tout ce que je ne sais pas classer. Il contient uniquement les concepts réellement génériques à plusieurs contextes. Si une classe emploie le langage du produit, elle appartient probablement à un contexte borné.
Étape 2 : 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, mais il rend visible une décision de conception importante : Symfony est l’environnement d’exécution qui démarre mon application. Il appartient à l’infrastructure et ne constitue pas le 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 est facultatif dans votre projet. La règle de dépendance est l’élément essentiel. Dans mon cas, ce déplacement l’a rendue plus visible et plus difficile à oublier.
Étape 3 : 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 demande la capacité dont elle a besoin : un 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, je configure explicitement la sélection. Ma passerelle de paiement en est un bon exemple : je dispose d’une implémentation en mémoire et d’une implémentation FlexPay, puis j’expose à l’application la passerelle configurée.
# 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\ConfigurableOnlinePaymentGatewayVoilà le rôle concret de l’inversion des dépendances : le cas d’usage possède l’interface, tandis que Symfony choisit l’adaptateur technique à la racine de composition.
Étape 4 : conserver les règles métier dans de simples objets PHP
Suivons 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;
}
}Observez ce qui est absent : aucun contrôleur, aucune requête, aucun gestionnaire d’entités et aucun attribut de mapping 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.
Étape 5 : 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ù ». C’est un port requis par un cas d’usage réel. Le domaine possède son langage, tandis qu’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.
Étape 6 : 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.
Étape 7 : 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.
Étape 8 : 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 : mon bus de commandes ajoute donc un middleware de transaction Doctrine. Les requêtes lisent l’état et n’ont pas besoin de cette transaction. Les événements autorisent l’absence de handler, car l’annonce d’un fait ne doit pas exiger l’existence d’un 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.
Étape 9 : 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 ». Il s’agit d’un compromis explicite, qui pourra être réexaminé et supprimé plus tard, plutôt que d’une dépendance dissimulée 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
Cette architecture a été façonnée par une contrainte réelle : Leganews possédait déjà des données précieuses dans une ancienne application MariaDB, tandis que la plateforme refactorisée utilise PostgreSQL.
En tant que cofondateur, je ne pouvais pas traiter cette réécriture comme un simple projet d’amélioration de la qualité du code. L’architecture 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é principale comprend le formatage, la validation de la configuration Symfony, l’analyse statique, une simulation Rector et Deptrac. La partie essentielle pour cet article est simple :
{
"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.
Conclusion
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.
Voilà la véritable valeur de cette architecture : je peux continuer à faire évoluer le système sans rendre chaque décision technique permanente.
Bon développement !