Architecture Hexagonale : comment l’adopter avec Symfony ?
Publié le
18 mars 2025

Introduction
Imaginez…
Vous êtes développeur PHP et vous devez concevoir une application de gestion de marchandises avec Symfony. Votre client impose de nombreuses règles logiques qui définissent son métier. Vous choisissez de structurer votre projet selon le principe MVC. Dans cette approche, les contrôleurs HTTP contiennent des conditions pour gérer les cas particuliers définis par le client et ils font directement appel au repository Doctrine pour persister les données.
Puis, votre CTO vous impose une contrainte supplémentaire : l’application doit être capable de s’adapter aux futurs changements dans l’infrastructure du système d’information. Il peut s’agir, par exemple, d’une migration de MySQL vers PostgreSQL ou d’une refonte complète du système de notifications.
Le problème : La logique métier est éparpillée dans du code censé être interchangeable. Controllers, DTO, notifiers e-mail, etc. Ce couplage fort rend l’application difficile à faire évoluer et complique également les tests ! Comment résoudre ce défi ?
Une solution à 6 côtés
L’architecture hexagonale est un paradigme qui consiste à isoler le cœur de votre application des éléments externes. Inventée en 2005 par Alistair Cockburn (co-auteur du manifeste Agile) elle améliore la testabilité, la maintenabilité et l’évolutivité des applications.
Son principe repose sur un découplage entre la logique métier et les services techniques, qui communiquent à travers un système de ports et d’adaptateurs. Trois concepts clés sont à retenir :
- Domaine métier : Au centre du schéma, il regroupe les règles métier et reste totalement indépendant du framework et des dépendances techniques.
- Ports : ils définissent des interfaces permettant au domaine d’interagir avec le monde extérieur. On en distingue deux types.
- Ports primaires (entrants) : Ces ports sont des interfaces ou des abstractions qui définissent des opérations sans se soucier de leur implémentation concrète. Ils sont utilisées par l’extérieur (API, scripts batch, Event Listener, etc) pour déclencher des actions.
- Port secondaires (sortants) : Ils définissent comment l’application interagit avec son environnement. Ces ports sont utilisées par le domaine métier pour effectuer une action (persistance, envoi d’e-mail, etc). Ils permettent de changer facilement les détails d'implémentation sans affecter le domaine métier.
- Adapters : Ce sont des classes qui implémentent ou consomment les ports et assurent la communication entre le domaine et le reste du système d’information sans interférer avec les règles du domaine métier.
Les schémas illustrant cette architecture adoptent une forme hexagonale, d’où son nom. Cependant, cette représentation est avant tout conceptuelle et doit être adaptée aux besoins spécifiques de chaque projet.
Implémenter l’architecture hexagonale avec Symfony
L’objectif de notre projet : créer un article avec une requête HTTP Post, en utilisant le framework Symfony 7 et l’ORM Doctrine.
L’architecture hexagonale repose sur des principes simples et n’impose aucune structure rigide dans l’organisation du code. Les choix présentés ici constituent donc une approche parmi d’autres.
Pour cette première version, nous optons pour une organisation basique en trois dossiers : “Domain” pour la logique métier et les ports, “Infrastructure” pour les adapters côté serveur et enfin “Application” pour les interactions avec l’utilisateur.
1. Business Logic
Nous créons un dossier 'Domain' contenant tout le code du domaine métier. Commençons avec le modèle “Article” :
// src/Domain/Model/Article.php
namespace App\Domain\Model;
class Article
{
public ?string $id = null;
public function __construct(
public string $title,
public string $content
) {}
}
Notons que l’isolation du code métier permet de s’affranchir des habitudes imposées par les frameworks. Donc n’hésitez pas à structurer “Domain” selon les termes qui conviennent le mieux aux experts métier.
2. Port entrant
Ensuite, créons un service applicatif permettant la création d’un article en prenant le soin de déléguer les tâches purement techniques à une interface.
// src/Domain/Service/ArticleService.php
namespace App\Domain\Service;
use App\Domain\Model\Article;
use App\Domain\Repository\ArticleRepositoryInterface;
class ArticleService
{
public function __construct(private readonly ArticleRepositoryInterface $repository) {}
public function createArticle(string $title, string $content): Article
{
$article = new Article($title, $content);
$this->repository->save($article);
return $article;
}
}
L’inversion de dépendance avec “ArticleRepositoryInterface” permet d'éviter le couplage avec nos choix techniques et cela facilite l'injection des mocks afin de tester unitairement l’intégralité de notre modèle.
3. Port sortant
Toujours dans le dossier Domain, nous ajoutons une interface qui présente les méthodes utilisées par ArticleService pour persister et récupérer les entités. N’étant appelée que par une classe du domaine métier, il s’agit d’un port secondaire.
// src/Domain/Repository/ArticleRepositoryInterface.php
namespace App\Domain\Repository;
use App\Domain\Model\Article;
interface ArticleRepositoryInterface
{
public function save(Article $article): void;
public function findById(string $id): ?Article;
}
4. Doctrine Adapter
Dans le dossier “Infrastructure”, nous ajoutons l’implémentation du port secondaire permettant d’accéder à la base de données :
// src/Infrastructure/Repository/DoctrineArticleRepository.php
namespace App\Infrastructure\Repository;
use App\Domain\Model\Article;
use App\Domain\Repository\ArticleRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineArticleRepository implements ArticleRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager) {}
public function save(Article $article): void
{
$this->entityManager->persist($article);
$this->entityManager->flush();
}
public function findById(string $id): ?Article
{
return $this->entityManager->getRepository(Article::class)->find($id);
}
}
Il est possible d’implémenter une même interface avec plusieurs autres classes selon les besoins : migration vers un autre système de base de données, tests automatisés, etc.
Pour que le repository fonctionne, il sera nécessaire de créer un configuration de mapping pour l'entité Article.
5. Application
Dans un dossier “Application”, nous créons un point d’entrée HTTP qui fait appel au service du domaine métier :
// src/Application/Controller/ArticleController.php
namespace App\Application\Controller;
use App\Domain\Service\ArticleService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class ArticleController extends AbstractController
{
public function __construct(private readonly ArticleService $articleService) {}
#[Route('/articles', methods: ['POST'])]
public function createArticle(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$article = $this->articleService->createArticle($data['title'], $data['content']);
return new JsonResponse(['id' => $article->id, 201);
}
}
6. Règles d’architecture
L’architecture hexagonale impose une séparation stricte des responsabilités entre le domaine, les ports et les adaptateurs. Cependant, il est facile de briser cela par négligence. Exemple : un contrôleur Symfony interagit avec le repository au lieu du service applicatif.
Deptrac permet d’automatiser le contrôle du respect de l’architecture en empêchant les dépendances interdites et en s’assurant que chaque couche communique uniquement avec les couches autorisées. Voici la configuration deptrac.yaml pour notre projet :
parameters:
paths:
- ./src
layers:
- name: Application
collectors:
- type: directory
value: src/Application/.*
- name: Domain
collectors:
- type: directory
value: src/Domain/.*
- name: Infrastructure
collectors:
- type: directory
value: src/Infrastructure/.*
- name: Framework
collectors:
- type: directory
value: vendor/symfony/.*
- name: Vendor
collectors:
- type: bool
must_not:
- type: directory
value: src/.*
ruleset:
Domain: ~
Application:
- Domain
- Framework
Infrastructure:
- Domain
- Application
- Vendor
Description des règles :
- “Domain” est totalement isolé, ce qui garantit qu’il ne contient que de la logique métier pure.
- “Application” interagit avec “Domain” et peut utiliser le framework Symfony.
- “Infrastructure” est libre de manipuler les objets du “Domain” tout en utilisant les librairies externes.
PHP Architecture Tester (phpat) est un autre outil permettant de définir des règles plus précises, notamment sur l’héritage des classes, avec phpunit. Ces deux outils sont bien sûr complémentaires.
Au-delà de l’hexagone
Pour des projets plus complexes, il est possible d’aller plus loin avec la Clean Architecture. Ce modèle, en plus de partager les avantages de l'architecture hexagonale, impose une structure en couches plus rigoureuse qui renforce l'indépendance vis-à-vis des frameworks, facilite les tests, et améliore la flexibilité face aux changements. Elle intègre naturellement les principes SOLID, rendant le code plus modulaire et compréhensible, ce qui est particulièrement bénéfique pour les projets à long terme ou sujets à des évolutions fréquentes.
L’architecture hexagonale permet d’obtenir des règles métier claires et constitue une porte d’entrée idéale vers le Domain-Driven Development (DDD). Cette approche favorise une meilleure collaboration entre développeurs et experts métier en alignant le code avec la réalité du domaine.
Bien que très efficace pour organiser une application modulaire, l’architecture hexagonale présente aussi quelques inconvénients. Elle requiert de former les développeurs et impose l’utilisation de DTO pour mapper les entités du domaine avec les librairies externes, ce qui allonge les temps de développement. Par conséquent, son adoption est moins pertinente pour des applications simples avec peu de règles métier (Anemic Domain Model).
Conclusion
En adoptant l'architecture hexagonale, nous avons créé une application évolutive et facilement compréhensible pour toutes les équipes. Cette approche assure un code propre, respectant les principes SOLID, et simplifie les tests unitaires. La séparation stricte des responsabilités entre le domaine métier, les ports et les adaptateurs offre une base solide pour les évolutions futures, tant techniques que fonctionnelles. Cette architecture apporte ainsi sérénité et flexibilité, tant pour les développeurs que pour le client, face aux changements inévitables dans le cycle de vie d'une application.
Avez-vous déjà appliqué ces principes dans vos projets ? Partagez votre expérience en commentaire.
Commentaires