ahmed-bhs/hexagonal-maker-bundle
layout: default
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.
"Une classe ne devrait avoir qu'une seule raison de changer"
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:
// 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:
"Ouvert à l'extension, fermé à la modification"
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.
// 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](https://github.com/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:
"Les objets doivent pouvoir être remplacés par des instances de leurs sous-types sans altérer le comportement"
// 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:
InMemoryUserRepository (rapide, pas de DB)DoctrineUserRepositoryCachedUserRepository"Ne pas forcer un client à dépendre d'interfaces qu'il n'utilise pas"
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
}
}
// 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:
"Dépendre d'abstractions, pas d'implémentations concrètes"
C'est le principe central de l'architecture hexagonale !
// 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:
// 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:
persist(), flush()) au lieu du vocabulaire métier (save())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.
// 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:
save(), ofId()) au lieu du vocabulaire technique (persist(), flush())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 |
%%{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
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:
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:
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());
}
}
%%{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["📦 Enti...
How can I help you explore Laravel packages today?