Architecture Hexagonale & Principes SOLID
Ce document explique en dĂ©tail comment lâarchitecture hexagonale respecte les principes SOLID, ses avantages par rapport Ă une architecture en couches traditionnelle, et les risques dâune mauvaise architecture.
Table des matiĂšres
- Les Principes SOLID
- Architecture Hexagonale vs Architecture en Couches
- Comment lâHexagonal Respecte SOLID
- Les Risques dâune Mauvaise Architecture
- Cas Concrets et Exemples
1. Les Principes SOLID
1.1 Single Responsibility Principle (SRP)
âUne classe ne devrait avoir quâune seule raison de changerâ
Violation (Architecture traditionnelle)
class UserController
{
public function register(Request $request): Response
{
// 1. Validation
if (!filter_var($request->get('email'), FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email');
}
// 2. Logique métier
$user = new User();
$user->setEmail($request->get('email'));
$user->setPassword(password_hash($request->get('password'), PASSWORD_BCRYPT));
// 3. Persistance
$this->entityManager->persist($user);
$this->entityManager->flush();
// 4. Envoi email
$this->mailer->send(new WelcomeEmail($user));
return new JsonResponse(['status' => 'ok']);
}
}
ProblĂšmes:
- Le contrÎleur a 4 responsabilités différentes
- Si la validation change â modification du contrĂŽleur
- Si la base de donnĂ©es change â modification du contrĂŽleur
- Si lâemail change â modification du contrĂŽleur
- Impossible Ă tester unitairement
Avec Architecture Hexagonale
// Controller (UI Layer) - Responsabilité: Traduire HTTP en Command
class UserController
{
public function register(Request $request): Response
{
$command = new RegisterCommand(
email: $request->get('email'),
password: $request->get('password')
);
$this->commandBus->dispatch($command);
return new JsonResponse(['status' => 'ok']);
}
}
// Command Handler (Application Layer) - Responsabilité: Orchestrer
#[AsMessageHandler]
class RegisterCommandHandler
{
public function __invoke(RegisterCommand $command): void
{
$user = $this->factory->create($command);
$this->repository->save($user);
$this->eventDispatcher->dispatch(new UserRegistered($user));
}
}
// Entity (Domain Layer) - Responsabilité: Logique métier
class User
{
public function __construct(
private Email $email, // Value Object avec validation
private HashedPassword $password
) {}
}
// Repository Adapter (Infrastructure) - Responsabilité: Persistance
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
}
Avantages:
- Chaque classe a UNE SEULE responsabilité
- Facile à tester indépendamment
- Changement isolé à un seul endroit
1.2 Open/Closed Principle (OCP)
âOuvert Ă lâextension, fermĂ© Ă la modificationâ
Violation
class NotificationService
{
public function send(User $user, string $type): void
{
if ($type === 'email') {
// Logique email
$this->mailer->send(...);
} elseif ($type === 'sms') {
// Logique SMS
$this->smsClient->send(...);
} elseif ($type === 'push') {
// Logique Push
$this->pushService->send(...);
}
// Si on ajoute Slack, il faut MODIFIER cette classe !
}
}
ProblĂšme: Pour ajouter un nouveau canal, on doit modifier le code existant.
Avec Architecture Hexagonale (Ports & Adapters)
// Port (Domain) - Interface stable
interface NotificationSenderInterface
{
public function send(Notification $notification): void;
public function supports(NotificationChannel $channel): bool;
}
// Adapter 1 - Email
class EmailNotificationSender implements NotificationSenderInterface
{
public function send(Notification $notification): void
{
$this->mailer->send(...);
}
public function supports(NotificationChannel $channel): bool
{
return $channel === NotificationChannel::EMAIL;
}
}
// Adapter 2 - SMS
class SmsNotificationSender implements NotificationSenderInterface
{
public function send(Notification $notification): void
{
$this->smsClient->send(...);
}
public function supports(NotificationChannel $channel): bool
{
return $channel === NotificationChannel::SMS;
}
}
// Adapter 3 - Slack (NOUVEAU - sans modifier le code existant!)
class SlackNotificationSender implements NotificationSenderInterface
{
public function send(Notification $notification): void
{
$this->slackClient->send(...);
}
public function supports(NotificationChannel $channel): bool
{
return $channel === NotificationChannel::SLACK;
}
}
// Application Layer - Utilise les adapters
class SendNotificationHandler
{
/** @param NotificationSenderInterface[] $senders */
public function __construct(private iterable $senders) {}
public function __invoke(SendNotificationCommand $cmd): void
{
foreach ($this->senders as $sender) {
if ($sender->supports($cmd->channel)) {
$sender->send($notification);
return;
}
}
}
}
Avantages:
- Ajouter Slack = créer une nouvelle classe, aucune modification du code existant
- Chaque adapter est indépendant
- Pas de risque de régression
1.3 Liskov Substitution Principle (LSP)
âLes objets doivent pouvoir ĂȘtre remplacĂ©s par des instances de leurs sous-types sans altĂ©rer le comportementâ
Avec Architecture Hexagonale
// Port (contrat stable)
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
}
// Adapter 1 - Production (Doctrine)
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
public function findById(UserId $id): ?User
{
return $this->em->find(User::class, $id->value);
}
}
// Adapter 2 - Tests (In Memory)
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function save(User $user): void
{
$this->users[$user->getId()->value] = $user;
}
public function findById(UserId $id): ?User
{
return $this->users[$id->value] ?? null;
}
}
// Adapter 3 - Cache
class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $decorated,
private CacheInterface $cache
) {}
public function findById(UserId $id): ?User
{
return $this->cache->get(
'user_' . $id->value,
fn() => $this->decorated->findById($id)
);
}
public function save(User $user): void
{
$this->decorated->save($user);
$this->cache->delete('user_' . $user->getId()->value);
}
}
// Application - Fonctionne avec N'IMPORTE quel adapter
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $repository // Peut ĂȘtre n'importe quelle implĂ©mentation
) {}
public function __invoke(RegisterCommand $cmd): void
{
$user = new User(...);
$this->repository->save($user); // Fonctionne avec les 3 adapters !
}
}
Avantages:
- Interchangeabilité totale des adapters
- Tests avec
InMemoryUserRepository(rapide, pas de DB) - Production avec
DoctrineUserRepository - Cache transparent avec
CachedUserRepository - Le handler ne sait pas et ne doit pas savoir quel adapter est utilisé
1.4 Interface Segregation Principle (ISP)
âNe pas forcer un client Ă dĂ©pendre dâinterfaces quâil nâutilise pasâ
Violation
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(int $id): ?User;
public function findAll(): array;
public function search(array $criteria): array;
public function count(): int;
public function export(string $format): string;
public function import(string $data): void;
public function backup(): void;
public function restore(string $backup): void;
}
// Un handler qui veut juste sauvegarder doit dépendre de 9 méthodes !
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $repository // Trop de méthodes inutiles
) {}
public function __invoke(RegisterCommand $cmd): void
{
$user = new User(...);
$this->repository->save($user); // Utilise seulement 1/9 des méthodes
}
}
Avec Architecture Hexagonale (Ports spécialisés)
// Port 1 - Pour l'écriture
interface UserWriterInterface
{
public function save(User $user): void;
}
// Port 2 - Pour la lecture simple
interface UserReaderInterface
{
public function findById(UserId $id): ?User;
}
// Port 3 - Pour la recherche
interface UserSearchInterface
{
public function search(UserSearchCriteria $criteria): array;
}
// Handlers utilisent UNIQUEMENT ce dont ils ont besoin
class RegisterUserHandler
{
public function __construct(
private UserWriterInterface $writer // Seulement 1 méthode
) {}
}
class FindUserHandler
{
public function __construct(
private UserReaderInterface $reader // Seulement 1 méthode
) {}
}
class SearchUsersHandler
{
public function __construct(
private UserSearchInterface $searcher // Méthodes de recherche uniquement
) {}
}
// Un adapter peut implémenter plusieurs ports
class DoctrineUserRepository implements
UserWriterInterface,
UserReaderInterface,
UserSearchInterface
{
public function save(User $user): void { ... }
public function findById(UserId $id): ?User { ... }
public function search(UserSearchCriteria $criteria): array { ... }
}
Avantages:
- Chaque handler dĂ©pend uniquement de ce quâil utilise
- Interfaces petites et cohérentes
- Facile Ă mocker pour les tests
1.5 Dependency Inversion Principle (DIP)
âDĂ©pendre dâabstractions, pas dâimplĂ©mentations concrĂštesâ
Câest le principe central de lâarchitecture hexagonale !
Violation 1 - Dépendance à des classes concrÚtes
// Violation DIRECTE du DIP - Dépend de classes concrÚtes
class RegisterUserService
{
public function __construct(
private EntityManager $em, // Classe concrĂšte Doctrine
private Mailer $mailer, // Classe concrĂšte Symfony
private FileLogger $logger // Classe concrĂšte
) {}
public function register(string $email, string $password): void
{
$user = new User();
$user->setEmail($email);
$this->em->persist($user);
$this->em->flush();
$this->mailer->send(...);
}
}
ProblĂšmes:
- DĂ©pend de classes concrĂštes au lieu dâabstractions
- Impossible de mocker pour les tests
- Impossible de changer lâimplĂ©mentation
Violation 2 - Abstractions dĂ©finies par lâinfrastructure (plus subtil)
// Violation ARCHITECTURALE du DIP - Utilise des interfaces,
// MAIS définies par l'infrastructure, pas par le Domain
class RegisterUserService
{
public function __construct(
private EntityManagerInterface $em, // Interface définie par Doctrine
private MailerInterface $mailer, // Interface définie par Symfony
private LoggerInterface $logger // Interface définie par PSR
) {}
public function register(string $email, string $password): void
{
$user = new User();
$user->setEmail($email);
$this->em->persist($user); // API Doctrine (persist/flush)
$this->em->flush();
$this->mailer->send(...); // API Symfony Mailer
}
}
ProblĂšme subtil mais critique:
- Bon point: Utilise des interfaces (mieux que des classes concrĂštes)
- MAIS: Ces interfaces sont dĂ©finies par lâinfrastructure (Doctrine/Symfony), pas par votre Domain
- Votre Application dĂ©pend de lâinfrastructure pour connaĂźtre les contrats
- Direction de dĂ©pendance incorrecte: Application â Infrastructure
- LâApplication utilise le vocabulaire technique de Doctrine (
persist(),flush()) au lieu du vocabulaire métier (save()) - Changer de Doctrine à autre chose nécessite de modifier tout le code qui utilise
persist()/flush()
Pourquoi câest une violation du DIP:
đŠ Domain/Application (haut niveau)
â dĂ©pend de
đ Infrastructure (bas niveau)
Le DIP dit: Les modules de haut niveau ne doivent PAS dĂ©pendre des modules de bas niveau. Les deux doivent dĂ©pendre dâabstractions.
Ici, votre Application (haut niveau) dépend de Doctrine/Symfony (bas niveau) pour définir les contrats.
Avec Architecture Hexagonale (DIP Correct)
// 1ïžâŁ Domain Layer - DĂFINIT ses propres abstractions (PORTS)
namespace App\User\Domain\Port;
interface UserRepositoryInterface // Interface définie par le DOMAIN
{
public function save(User $user): void; // Vocabulaire métier
public function ofId(UserId $id): ?User; // Vocabulaire métier
}
interface EmailSenderInterface // Interface définie par le DOMAIN
{
public function sendWelcomeEmail(User $user): void; // Vocabulaire métier
}
// 2ïžâŁ Application Layer - DĂ©pend UNIQUEMENT des abstractions du Domain
namespace App\User\Application;
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $repository, // Port du Domain
private EmailSenderInterface $emailSender // Port du Domain
) {}
public function __invoke(RegisterCommand $cmd): void
{
$user = User::register(
new Email($cmd->email),
HashedPassword::fromPlain($cmd->password)
);
$this->repository->save($user); // Vocabulaire métier
$this->emailSender->sendWelcomeEmail($user); // Vocabulaire métier
}
}
// 3ïžâŁ Infrastructure Layer - IMPLĂMENTE les abstractions du Domain
namespace App\User\Infrastructure\Persistence;
class DoctrineUserRepository implements UserRepositoryInterface // Implémente le port
{
public function __construct(
private EntityManagerInterface $em // Doctrine utilisé ICI seulement
) {}
public function save(User $user): void
{
$this->em->persist($user); // Détails techniques cachés ici
$this->em->flush();
}
public function ofId(UserId $id): ?User
{
return $this->em->find(User::class, $id->value());
}
}
namespace App\User\Infrastructure\Messaging;
class SymfonyEmailSender implements EmailSenderInterface // Implémente le port
{
public function __construct(
private MailerInterface $mailer // Symfony Mailer utilisé ICI seulement
) {}
public function sendWelcomeEmail(User $user): void
{
$email = (new Email())
->to($user->email()->value())
->subject('Welcome!')
->html('...');
$this->mailer->send($email); // Détails techniques cachés ici
}
}
Direction des dépendances (CORRECTE):
đ Infrastructure (DoctrineUserRepository, SymfonyEmailSender)
â implements
đ Domain Ports (UserRepositoryInterface, EmailSenderInterface)
â uses
âïž Application (RegisterUserHandler)
â uses
đ Domain (User, Email, HashedPassword)
Tous les modules dĂ©pendent du Domain, pas lâinverse !
Flux de dépendances:
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'15px'}}}%%
graph BT
Infra["đ Infrastructure Adapters<br/><small>DoctrineUserRepository</small>"]
Port["đ Domain Ports<br/><small>UserRepositoryInterface</small>"]
App["âïž Application<br/><small>RegisterUserHandler</small>"]
Infra -.->|"đŻ implements"| Port
App ==>|"uses"| Port
style Port fill:#FFF9C4,stroke:#F57F17,stroke-width:3px,color:#000
style App fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style Infra fill:#F8BBD0,stroke:#C2185B,stroke-width:3px,color:#000
LâInfrastructure dĂ©pend du Domain, PAS lâinverse !
Avantages:
- Le Domain ne dépend de RIEN (zéro dépendance externe)
- LâApplication dĂ©pend uniquement du Domain (pas de lâinfrastructure)
- LâInfrastructure dĂ©pend du Domain et implĂ©mente ses ports
- Les interfaces sont définies par le Domain (votre métier), pas par Doctrine/Symfony
- Le code utilise le vocabulaire métier (
save(),ofId()) au lieu du vocabulaire technique (persist(),flush()) - Changement dâinfrastructure = crĂ©er un nouvel adapter, zĂ©ro modification du Domain/Application
Comparaison concrĂšte:
| Aspect | Violation DIP | Hexagonal (DIP Correct) |
|---|---|---|
| Qui dĂ©finit lâinterface? | Doctrine/Symfony | Votre Domain |
| Direction dĂ©pendance | App â Infrastructure | Infrastructure â Domain |
| Vocabulaire utilisé | Technique (persist, flush) |
Métier (save, ofId) |
| Changer Doctrine | Modifier tout le code | Créer nouvel adapter |
| Tests | Dépend de Doctrine | In-memory (rapide) |
| Framework upgrade | Casse lâapplication | Modifier adapters seulement |
2. Architecture Hexagonale vs Architecture en Couches
Architecture en Couches Traditionnelle (Layered)
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'15px'}}}%%
graph TD
Presentation["đź Presentation Layer<br/><small>Controllers</small>"]
Business["âïž Business Layer<br/><small>Services</small>"]
DataAccess["đïž Data Access Layer<br/><small>Repositories, ORM</small>"]
Database["đŸ Database"]
Presentation ==>|"đȘïž depends on"| Business
Business ==>|"đȘïž depends on"| DataAccess
DataAccess ==>|"đȘïž depends on"| Database
style Presentation fill:#E1BEE7,stroke:#6A1B9A,stroke-width:3px,color:#000
style Business fill:#FFF9C4,stroke:#F57C00,stroke-width:3px,color:#000
style DataAccess fill:#FFCCBC,stroke:#D84315,stroke-width:3px,color:#000
style Database fill:#FFCDD2,stroke:#C62828,stroke-width:4px,color:#000
2.1.1 ProblĂšmes de lâArchitecture en Couches
1. Dépendance vers le bas (Database Centric)
// Business Layer dépend de la Data Layer
class UserService
{
public function __construct(
private EntityManagerInterface $em // Couplé à Doctrine
) {}
public function registerUser(string $email): void
{
$user = new User(); // Entity Doctrine avec annotations
$user->setEmail($email);
$this->em->persist($user);
$this->em->flush();
}
}
Conséquences:
- Impossible de tester sans base de données
- Changement de base de données = réécriture du Business Layer
- La logique métier est couplée à la technologie de persistance
- Les Entities contiennent des annotations Doctrine (pas de Domain pur)
2. Logique métier diluée
// Entity avec annotations Doctrine - PAS un vrai Domain Model
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string')]
private string $email;
// Getters/Setters - PAS de logique métier
public function setEmail(string $email): void
{
$this->email = $email; // Pas de validation
}
}
// Service contient toute la logique
class UserService
{
public function registerUser(string $email): void
{
// Validation dans le service (devrait ĂȘtre dans le domain)
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid email');
}
$user = new User();
$user->setEmail($email); // Entity = simple conteneur de données
$this->em->persist($user);
$this->em->flush();
}
}
Conséquences:
- Entity = conteneur de données sans logique (Anemic Domain Model)
- Logique métier éparpillée dans les Services
- Difficile de comprendre les rÚgles métier
- Duplication de la validation dans plusieurs services
3. Difficile Ă tester
class UserServiceTest extends TestCase
{
public function testRegisterUser(): void
{
// Besoin d'une vraie base de données
$entityManager = $this->createEntityManager();
// Besoin de fixtures
$this->loadFixtures();
$service = new UserService($entityManager);
$service->registerUser('test@example.com');
// Test lent (I/O database)
$user = $entityManager->find(User::class, 1);
$this->assertEquals('test@example.com', $user->getEmail());
}
}
Architecture Hexagonale (Ports & Adapters)
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
subgraph UI["đź UI - Primary Adapters"]
HTTP["đ HTTP Controllers"]
CLI["âšïž CLI Commands"]
GraphQL["đ GraphQL API"]
gRPC["đ gRPC Service"]
end
subgraph APP["âïž Application Layer"]
UseCases["đš Use Cases<br/><small>Command Handlers<br/>Query Handlers</small>"]
end
subgraph DOMAIN["đ Domain Layer - CORE"]
Entities["đŠ Entities"]
VOs["đŻ Value Objects"]
Ports["đ Ports<br/><small>Interfaces</small>"]
end
subgraph INFRA["đ Infrastructure - Secondary Adapters"]
Doctrine["đïž Doctrine ORM"]
Redis["⥠Redis Cache"]
API["đ External APIs"]
end
UI ==>|"uses"| APP
APP ==>|"đŻ depends on"| DOMAIN
INFRA -.->|"đŻ implements"| Ports
style DOMAIN fill:#C8E6C9,stroke:#2E7D32,stroke-width:4px,color:#000
style APP fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style UI fill:#E1BEE7,stroke:#6A1B9A,stroke-width:3px,color:#000
style INFRA fill:#F8BBD0,stroke:#C2185B,stroke-width:3px,color:#000
2.2.1 Avantages de lâArchitecture Hexagonale
1. Domain indépendant (Domain Centric)
// Domain Layer - Pur PHP, AUCUNE dépendance
namespace App\User\Domain\Model;
final class User
{
public function __construct(
private UserId $id,
private Email $email, // Value Object avec validation
private bool $isActive = false
) {}
// Logique métier dans le domain
public function activate(): void
{
if ($this->isActive) {
throw new UserAlreadyActiveException();
}
$this->isActive = true;
}
public function changeEmail(Email $newEmail): void
{
// RĂšgle mĂ©tier: email doit ĂȘtre diffĂ©rent
if ($this->email->equals($newEmail)) {
throw new SameEmailException();
}
$this->email = $newEmail;
}
}
// Value Object avec validation
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
Avantages:
- Logique métier centralisée dans le Domain
- ZĂ©ro dĂ©pendance externe (pas dâannotations)
- Validation encapsulée dans les Value Objects
- RÚgles métier explicites et testables
2. Testabilité
// Test du Domain - Ultra rapide, aucune dépendance
class UserTest extends TestCase
{
public function testUserCanBeActivated(): void
{
$user = new User(
id: UserId::generate(),
email: new Email('test@example.com')
);
$user->activate();
$this->assertTrue($user->isActive());
}
public function testCannotActivateTwice(): void
{
$user = new User(
id: UserId::generate(),
email: new Email('test@example.com')
);
$user->activate();
$this->expectException(UserAlreadyActiveException::class);
$user->activate(); // Should throw
}
}
// Test du Handler avec In-Memory repository
class RegisterUserHandlerTest extends TestCase
{
public function testUserIsRegistered(): void
{
$repository = new InMemoryUserRepository(); // Pas de DB
$handler = new RegisterUserHandler($repository);
$command = new RegisterCommand('test@example.com', 'password');
$handler($command);
$this->assertCount(1, $repository->all());
}
}
Avantages:
- Tests ultra-rapides (pas dâI/O)
- Tests isolés (pas de DB, pas de fixtures)
- Tests du domain sans framework
3. Flexibilité technologique
// En développement: In-Memory
$container->set(UserRepositoryInterface::class, InMemoryUserRepository::class);
// En production: Doctrine
$container->set(UserRepositoryInterface::class, DoctrineUserRepository::class);
// En production avec cache: Decorator
$container->set(UserRepositoryInterface::class, CachedUserRepository::class);
Avantages:
- Changement de technologie sans toucher au code métier
- Plusieurs implémentations possibles
- Stack technology evolutive
3. Les Risques dâune Mauvaise Architecture
1. Le ModÚle Anémique (Anemic Domain Model)
3.1.1 Anti-pattern
// Entity = simple conteneur de données
class Order
{
private int $id;
private string $status;
private float $total;
// Getters/Setters uniquement
public function setStatus(string $status): void
{
$this->status = $status; // Aucune validation
}
public function setTotal(float $total): void
{
$this->total = $total; // Peut ĂȘtre nĂ©gatif
}
}
// Service contient toute la logique
class OrderService
{
public function placeOrder(Order $order): void
{
// Validation éparpillée dans le service
if ($order->getTotal() < 0) {
throw new Exception('Invalid total');
}
$order->setStatus('confirmed'); // String magic
$this->em->persist($order);
$this->em->flush();
}
public function cancelOrder(Order $order): void
{
// Duplication de la logique de validation
if ($order->getStatus() === 'shipped') {
throw new Exception('Cannot cancel shipped order');
}
$order->setStatus('cancelled');
$this->em->flush();
}
}
Risques:
- Duplication de la logique dans plusieurs services
- Incohérence (différents services peuvent avoir des rÚgles différentes)
- Bugs difficiles à trouver (pas de validation centralisée)
- Maintenance cauchemardesque (logique éparpillée)
3.1.2 Rich Domain Model (Hexagonal)
enum OrderStatus: string
{
case PENDING = 'pending';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';
}
final class Order
{
public function __construct(
private OrderId $id,
private Money $total,
private OrderStatus $status = OrderStatus::PENDING
) {
if ($total->amount <= 0) {
throw new InvalidOrderTotalException();
}
}
// Logique métier encapsulée
public function confirm(): void
{
if ($this->status !== OrderStatus::PENDING) {
throw new OrderCannotBeConfirmedException($this->status);
}
$this->status = OrderStatus::CONFIRMED;
}
public function cancel(): void
{
if ($this->status === OrderStatus::SHIPPED) {
throw new ShippedOrderCannotBeCancelledException();
}
$this->status = OrderStatus::CANCELLED;
}
public function ship(): void
{
if ($this->status !== OrderStatus::CONFIRMED) {
throw new OrderMustBeConfirmedBeforeShippingException();
}
$this->status = OrderStatus::SHIPPED;
}
}
// Handler = simple orchestration
class ConfirmOrderHandler
{
public function __invoke(ConfirmOrderCommand $cmd): void
{
$order = $this->repository->findById($cmd->orderId);
$order->confirm(); // Logique dans le domain
$this->repository->save($order);
}
}
Avantages:
- Logique centralisĂ©e dans lâentity
- Impossible de mettre lâobjet dans un Ă©tat invalide
- Type-safe (enum au lieu de string)
- RÚgles métier explicites
2. Le Couplage Fort (Tight Coupling)
3.2.1 Couplage fort
class UserService
{
public function __construct(
private EntityManagerInterface $em,
private Swift_Mailer $mailer, // Couplé à SwiftMailer
private MonologLogger $logger // Couplé à Monolog
) {}
public function register(string $email): void
{
$user = new User();
$user->setEmail($email);
$this->em->persist($user);
$this->em->flush();
// Si on veut changer de mailer, il faut modifier tout ça
$message = (new Swift_Message('Welcome'))
->setTo($email)
->setBody('Welcome!');
$this->mailer->send($message);
}
}
Risques:
- Impossible de changer SwiftMailer sans réécrire le code
- Tests difficiles (besoin de vraies dépendances)
- Migration framework impossible (couplage fort)
3.2.2 Faible couplage (Hexagonal)
// Port
interface EmailSenderInterface
{
public function send(Email $email): void;
}
// Domain
final readonly class WelcomeEmail
{
public function __construct(
public string $to,
public string $subject,
public string $body
) {}
}
// Handler
class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $repository,
private EmailSenderInterface $emailSender // Abstraction
) {}
public function __invoke(RegisterCommand $cmd): void
{
$user = new User(...);
$this->repository->save($user);
$email = new WelcomeEmail(
to: $user->getEmail()->value,
subject: 'Welcome',
body: 'Welcome to our platform!'
);
$this->emailSender->send($email); // Ne sait pas comment
}
}
// Adapter 1 - SwiftMailer
class SwiftMailerAdapter implements EmailSenderInterface
{
public function send(Email $email): void
{
$message = (new Swift_Message($email->subject))
->setTo($email->to)
->setBody($email->body);
$this->mailer->send($message);
}
}
// Adapter 2 - Symfony Mailer (migration facile!)
class SymfonyMailerAdapter implements EmailSenderInterface
{
public function send(Email $email): void
{
$message = (new Email())
->to($email->to)
->subject($email->subject)
->text($email->body);
$this->mailer->send($message);
}
}
Avantages:
- Migration SwiftMailer â Symfony Mailer sans toucher au handler
- Tests avec
FakeEmailSender - Peut avoir plusieurs adapters (email + SMS)
3. Le Big Ball of Mud
3.3.1 Sans architecture
// Tout est mélangé dans le controller
class OrderController
{
public function create(Request $request): Response
{
// Validation
if (empty($request->get('items'))) {
return new JsonResponse(['error' => 'No items'], 400);
}
// Calcul métier
$total = 0;
foreach ($request->get('items') as $item) {
$product = $this->em->find(Product::class, $item['id']);
$total += $product->getPrice() * $item['quantity'];
}
// Création
$order = new Order();
$order->setTotal($total);
$order->setStatus('pending');
// Persistance
$this->em->persist($order);
$this->em->flush();
// Email
$this->mailer->send(...);
// Log
$this->logger->info('Order created: ' . $order->getId());
// Envoi événement
$this->eventBus->dispatch(new OrderCreated($order));
return new JsonResponse(['id' => $order->getId()]);
}
}
Risques:
- Impossible à tester (trop de dépendances)
- Impossible à maintenir (tout est mélangé)
- Impossible à réutiliser (couplé au HTTP)
- Impossible dâajouter une API GraphQL (logique dans le controller)
3.3.2 Avec Hexagonal
// Controller = adapter HTTP
class OrderController
{
public function create(Request $request): Response
{
$command = new CreateOrderCommand(
items: $request->get('items'),
customerId: $request->get('customer_id')
);
$orderId = $this->commandBus->dispatch($command);
return new JsonResponse(['id' => $orderId]);
}
}
// GraphQL = adapter GraphQL (rĂ©utilise la mĂȘme logique!)
class OrderMutation
{
public function createOrder(array $items, string $customerId): string
{
$command = new CreateOrderCommand($items, $customerId);
return $this->commandBus->dispatch($command);
}
}
// CLI = adapter CLI (mĂȘme logique!)
class CreateOrderCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$command = new CreateOrderCommand(
items: json_decode($input->getArgument('items'), true),
customerId: $input->getArgument('customer-id')
);
$this->commandBus->dispatch($command);
return Command::SUCCESS;
}
}
Avantages:
- Une seule implémentation réutilisée par HTTP, GraphQL, CLI
- Testable indépendamment
- Ăvolutif (ajouter gRPC = nouvel adapter)
4. Tableau Comparatif
| Aspect | Architecture Layered | Architecture Hexagonale |
|---|---|---|
| Dépendances | Vers le bas (DB-centric) | Vers le centre (Domain-centric) |
| Testabilité | Tests lents (DB requise) | Tests rapides (in-memory) |
| Logique mĂ©tier | ĂparpillĂ©e dans services | CentralisĂ©e dans Domain |
| Couplage | Fort (framework, ORM) | Faible (ports/adapters) |
| Changement techno | Réécriture massive | Nouveau adapter |
| RĂ©utilisabilitĂ© | Difficile | Facile (mĂȘme use case, plusieurs adapters) |
| Principe SOLID | Violations fréquentes | Respect total |
| Complexité initiale | Faible | Moyenne |
| Maintenabilité long terme | Difficile | Excellente |
5. Conclusion
Quand utiliser lâArchitecture Hexagonale ?
Utiliser Hexagonal si:
- Projet complexe avec beaucoup de logique métier
- Projet long terme (maintenance sur plusieurs années)
- Ăquipe qui grandit
- Besoin de plusieurs interfaces (API REST, GraphQL, CLI)
- Tests automatisés critiques
- Stack technologique susceptible dâĂ©voluer
Ne pas utiliser si:
- Prototype rapide
- CRUD simple sans logique métier
- Projet jetable (< 6 mois)
- Ăquipe trĂšs junior (courbe dâapprentissage)
Principes SOLID = Fondation de lâHexagonal
Lâarchitecture hexagonale nâest pas âen plusâ de SOLID, câest lâapplication concrĂšte des principes SOLID Ă lâĂ©chelle dâune application :
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
subgraph SOLID["đ Principes SOLID"]
SRP["đŻ Single Responsibility<br/><small>Une responsabilitĂ© par classe</small>"]
OCP["đ Open/Closed<br/><small>Ouvert extension, fermĂ© modification</small>"]
LSP["đ Liskov Substitution<br/><small>Contrats respectĂ©s</small>"]
ISP["âïž Interface Segregation<br/><small>Interfaces spĂ©cialisĂ©es</small>"]
DIP["âŹïž Dependency Inversion<br/><small>DĂ©pendre d'abstractions</small>"]
end
subgraph HEX["đïž Architecture Hexagonale"]
Layers["đ SĂ©paration en couches"]
Ports["đ Ports - Interfaces"]
Adapters["đ Adapters - ImplĂ©mentations"]
Core["đ Domain - CĆur isolĂ©"]
end
SRP ==>|"appliqué à "| Layers
OCP ==>|"permis par"| Adapters
LSP ==>|"garanti par"| Adapters
ISP ==>|"implémenté via"| Ports
DIP ==>|"matérialisé par"| Core
style SOLID fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style HEX fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px,color:#000
style Core fill:#FFF9C4,stroke:#F57F17,stroke-width:3px,color:#000
Correspondances:
- SRP â Chaque couche a une responsabilitĂ©
- OCP â Nouveaux adapters sans modifier le code existant
- LSP â Adapters interchangeables
- ISP â Ports spĂ©cialisĂ©s
- DIP â Domain ne dĂ©pend de rien, Infrastructure dĂ©pend du Domain
Rappel: Ce bundle vous aide à respecter ces principes automatiquement en générant la bonne structure.