ahmed-bhs/hexagonal-maker-bundle
layout: default
Comprendre comment une requête traverse toutes les couches de l'architecture hexagonale est crucial. Ce guide montre le voyage complet de la requête HTTP à la base de données et retour, avec les transformations de données à chaque frontière.
JSON HTTP → Contrôleur → DTO Command → Handler → Entité Domain → Port Repository → Adaptateur Doctrine → Base de Données → Entité → Résultat Query → DTO Response → Contrôleur → JSON HTTP
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'13px'}}}%%
sequenceDiagram
autonumber
participant Client as 🌐 Client<br/>(Navigateur/API)
participant Router as 🚦 Router Symfony
participant Ctrl as 🎮 Contrôleur<br/>(Infrastructure)
participant Valid as ✅ Validateur<br/>(Symfony)
participant Bus as 🚌 Message Bus<br/>(Symfony)
participant Handler as ⚙️ Handler<br/>(Application)
participant Factory as 🏭 Factory<br/>(Domaine)
participant Entity as 💎 Entité<br/>(Domaine)
participant Port as 🔌 Port<br/>(Interface Domaine)
participant Adapter as 🔧 Adaptateur<br/>(Infrastructure)
participant DB as 🗄️ Base de Données<br/>(PostgreSQL)
rect rgb(255, 240, 240)
Note over Client,Router: REQUÊTE ENTRANTE
Client->>Router: POST /api/users<br/>{"email": "user@example.com", "password": "secret123"}
Router->>Ctrl: Route vers RegisterUserController
end
rect rgb(240, 248, 255)
Note over Ctrl,Valid: COUCHE INFRASTRUCTURE : Validation Entrée
Ctrl->>Ctrl: Désérialiser JSON vers DTO RegisterUserRequest
Ctrl->>Valid: Valider contraintes DTO
Valid-->>Ctrl: Validation OK
Ctrl->>Bus: Créer RegisterUserCommand<br/>dispatch(command)
end
rect rgb(240, 255, 240)
Note over Bus,Handler: COUCHE APPLICATION : Orchestration
Bus->>Handler: __invoke(RegisterUserCommand)
Handler->>Port: $this->users->existsByEmail()
Port->>Adapter: existsByEmail()
Adapter->>DB: SELECT COUNT(*) FROM users WHERE email = ?
DB-->>Adapter: 0
Adapter-->>Port: false
Port-->>Handler: false (email disponible)
end
rect rgb(255, 255, 240)
Note over Handler,Entity: COUCHE DOMAINE : Logique Métier
Handler->>Factory: UserFactory::create(email, password)
Factory->>Entity: new Email(value)
Entity->>Entity: valider format email
Entity-->>Factory: Email créé
Factory->>Entity: HashedPassword::fromPlaintext()
Entity->>Entity: hasher mot de passe + valider longueur
Entity-->>Factory: HashedPassword créé
Factory->>Entity: new User(id, email, password)
Entity->>Entity: appliquer règles métier
Entity-->>Factory: Entité User
Factory-->>Handler: Entité User
end
rect rgb(240, 248, 255)
Note over Handler,DB: COUCHE INFRASTRUCTURE : Persistance
Handler->>Port: $this->users->save($user)
Port->>Adapter: save($user)
Adapter->>DB: INSERT INTO users (...) VALUES (...)
DB-->>Adapter: OK
Adapter-->>Port: void
Port-->>Handler: void
end
rect rgb(255, 240, 240)
Note over Handler,Client: CHEMIN DE RÉPONSE
Handler-->>Bus: void (succès)
Bus-->>Ctrl: void
Ctrl->>Ctrl: Créer DTO UserResponse<br/>depuis entité User
Ctrl-->>Router: Response(201, UserResponse)
Router-->>Client: 201 Created<br/>{"id": "123", "email": "user@example.com"}
end
Entrée: POST /api/users HTTP/1.1
Content-Type: application/json
{"email": "user@example.com", "password": "secret123"}
Action: Le Router Symfony matche la route → RegisterUserController
// Le contrôleur reçoit la requête brute
public function __invoke(Request $request): JsonResponse
{
// Désérialiser JSON vers DTO
$dto = $this->serializer->deserialize(
$request->getContent(),
RegisterUserRequest::class,
'json'
);
// $dto est maintenant : RegisterUserRequest {
// email: "user@example.com",
// password: "secret123"
// }
}
Chaîne JSON Brute → DTO RegisterUserRequest (Infrastructure)
// Valider avec contraintes Symfony
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
throw new ValidationException($errors);
}
// Classe DTO avec contraintes :
class RegisterUserRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password;
}
// Transformer DTO → Command (DTO Application)
$command = new RegisterUserCommand(
email: $dto->email,
password: $dto->password
);
// Dispatcher vers message bus
$this->messageBus->dispatch($command);
DTO RegisterUserRequest → DTO RegisterUserCommand (Application)
// Symfony invoque automatiquement le handler
#[AsMessageHandler]
final readonly class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
// Le handler démarre l'orchestration
}
}
// Le handler appelle le port
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException($command->email);
}
// Interface port (Domaine)
interface UserRepositoryInterface
{
public function existsByEmail(string $email): bool;
}
// Implémentation adaptateur (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function existsByEmail(string $email): bool
{
$qb = $this->entityManager->createQueryBuilder();
$qb->select('COUNT(u.id)')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email);
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
}
// Requête base de données exécutée :
// SELECT COUNT(id) FROM users WHERE email = 'user@example.com'
Command (chaîne email)
→ Appel méthode Port
→ Adaptateur (Doctrine QueryBuilder)
→ Requête SQL
→ Base de Données
→ Résultat (0)
→ Adaptateur (false)
→ Port (false)
→ Handler (continue)
// Le handler délègue la création à la factory
$user = UserFactory::create($command->email, $command->password);
// La factory crée le value object Email
$email = new Email($command->email);
// Le constructeur Email valide
final readonly class Email
{
public function __construct(public string $value)
{
// Règle métier : doit être un email valide
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
// Règle métier : domaine entreprise uniquement (exemple)
if (!str_ends_with($value, '[@company](https://github.com/company).com')) {
throw new InvalidEmailDomainException($value);
}
}
}
Chaîne primitive → Value Object Email (Domaine)
// La factory crée le mot de passe haché
$hashedPassword = HashedPassword::fromPlaintext($command->password);
// Le value object gère le hachage
final readonly class HashedPassword
{
private function __construct(public string $hash) {}
public static function fromPlaintext(string $plaintext): self
{
// Règle métier : longueur minimale
if (strlen($plaintext) < 8) {
throw new PasswordTooShortException();
}
// Hasher le mot de passe
$hash = password_hash($plaintext, PASSWORD_ARGON2ID);
return new self($hash);
}
}
Chaîne en clair → Value Object HashedPassword (Domaine)
// La factory crée l'entité avec tous les value objects
public static function create(string $email, string $password): User
{
return new User(
id: UserId::generate(),
email: new Email($email),
password: HashedPassword::fromPlaintext($password),
isActive: false,
createdAt: new \DateTimeImmutable()
);
}
// Le constructeur de l'entité applique les règles métier
public function __construct(
private UserId $id,
private Email $email,
private HashedPassword $password,
private bool $isActive,
private \DateTimeImmutable $createdAt,
) {
// Invariant métier : nouveaux utilisateurs inactifs
if ($this->isActive) {
throw new NewUserCannotBeActiveException();
}
}
Primitives (string, string)
→ Value Objects (Email, HashedPassword)
→ Entité (User) [Domaine]
// Le handler sauvegarde l'entité via le port
$this->users->save($user);
// Interface port (Domaine)
interface UserRepositoryInterface
{
public function save(User $user): void;
}
// Implémentation adaptateur (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}
// Doctrine génère le SQL :
// INSERT INTO users (id, email, password, is_active, created_at)
// VALUES ('550e8400-...', 'user@example.com', '$argon2id$...', false, '2024-01-15 10:30:00')
Entité User (Domaine)
→ Mapping Metadata Doctrine
→ Instruction SQL INSERT
→ Ligne Base de Données
// Le handler se termine (retourne void)
public function __invoke(RegisterUserCommand $command): void
{
// ... toutes les étapes complétées
// Pas de valeur de retour (pattern command)
}
// Le contrôleur reçoit void, crée la réponse
public function __invoke(Request $request): JsonResponse
{
$command = new RegisterUserCommand(/*...*/);
$this->messageBus->dispatch($command);
// Récupérer l'utilisateur créé pour le retourner
$user = $this->users->findByEmail($command->email);
// Transformer Entité → DTO Response
$response = new UserResponse(
id: $user->getId()->toString(),
email: $user->getEmail()->value,
isActive: $user->isActive(),
createdAt: $user->getCreatedAt()->format('c')
);
return new JsonResponse($response, Response::HTTP_CREATED);
}
Entité User (Domaine) → DTO UserResponse (Infrastructure) → JSON
Sortie: HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"isActive": false,
"createdAt": "2024-01-15T10:30:00+00:00"
}
1. JSON Brut (HTTP)
↓
2. DTO RegisterUserRequest (Infrastructure - Validation entrée)
↓
3. RegisterUserCommand (Application - Intention cas d'usage)
↓
4. Email + Password (chaînes)
↓
5. Value Object Email + Value Object HashedPassword (Domaine - Validation métier)
↓
6. Entité User (Domaine - Logique métier)
↓
7. Metadata Entité Doctrine (Infrastructure - Mapping ORM)
↓
8. SQL INSERT (Infrastructure - Base de données)
↓
9. Ligne Base de Données (Persistance)
↓
10. Entité User (Domaine - Chargée depuis BD)
↓
11. DTO UserResponse (Infrastructure - Formatage sortie)
↓
12. Réponse JSON (HTTP)
| Transformation | Objectif | Couche |
|---|---|---|
| JSON → DTO Request | Validation entrée, préoccupations HTTP | Infrastructure |
| DTO Request → Command | Intention cas d'usage, préoccupation application | Application |
| Command → Value Objects | Validation métier | Domaine |
| Value Objects → Entité | Encapsulation logique métier | Domaine |
| Entité → SQL | Mapping persistance | Infrastructure |
| SQL → Ligne BD | Stockage | Infrastructure |
| Entité → DTO Response | Formatage sortie, cacher internes | Infrastructure |
// 1. INFRASTRUCTURE : Contrôleur
namespace App\User\Infrastructure\Controller;
#[Route('/api/users', methods: ['POST'])]
final readonly class RegisterUserController extends AbstractController
{
public function __invoke(Request $request): JsonResponse
{
// Désérialiser + valider
$dto = $this->serializer->deserialize(
$request->getContent(),
RegisterUserRequest::class,
'json'
);
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
throw new ValidationException($violations);
}
// Créer command
$command = new RegisterUserCommand(
email: $dto->email,
password: $dto->password
);
// Dispatcher
$this->messageBus->dispatch($command);
// Récupérer résultat
$user = $this->users->findByEmail($command->email);
// Créer réponse
return $this->json(
new UserResponse(
id: $user->getId()->toString(),
email: $user->getEmail()->value,
isActive: $user->isActive()
),
Response::HTTP_CREATED
);
}
}
// 2. APPLICATION : Command (DTO)
namespace App\User\Application\Command;
final readonly class RegisterUserCommand
{
public function __construct(
public string $email,
public string $password,
) {}
}
// 3. APPLICATION : Handler
namespace App\User\Application\Handler;
#[AsMessageHandler]
final readonly class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users,
private EventDispatcherInterface $eventDispatcher,
) {}
public function __invoke(RegisterUserCommand $command): void
{
// Vérifier unicité (préoccupation application - nécessite repository)
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException($command->email);
}
// Créer utilisateur (logique domaine dans factory)
$user = UserFactory::create($command->email, $command->password);
// Persister (préoccupation infrastructure)
$this->users->save($user);
// Dispatcher événement (préoccupation infrastructure)
$this->eventDispatcher->dispatch(
new UserRegisteredEvent($user->getId())
);
}
}
// 4. DOMAINE : Factory
namespace App\User\Domain\Factory;
final class UserFactory
{
public static function create(string $email, string $password): User
{
return new User(
id: UserId::generate(),
email: new Email($email), // Valide format
password: HashedPassword::fromPlaintext($password), // Valide + hash
isActive: false,
createdAt: new \DateTimeImmutable()
);
}
}
// 5. DOMAINE : Value Objects
namespace App\User\Domain\ValueObject;
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
}
}
final readonly class HashedPassword
{
private function __construct(public string $hash) {}
public static function fromPlaintext(string $plaintext): self
{
if (strlen($plaintext) < 8) {
throw new PasswordTooShortException();
}
return new self(password_hash($plaintext, PASSWORD_ARGON2ID));
}
}
// 6. DOMAINE : Entité
namespace App\User\Domain\Model;
class User
{
public function __construct(
private UserId $id,
private Email $email,
private HashedPassword $password,
private bool $isActive,
private \DateTimeImmutable $createdAt,
) {}
public function activate(): void
{
if ($this->isActive) {
throw new UserAlreadyActiveException();
}
$this->isActive = true;
}
// Getters...
}
// 7. DOMAINE : Port (Interface)
namespace App\User\Domain\Port;
interface UserRepositoryInterface
{
public function save(User $user): void;
public function existsByEmail(string $email): bool;
public function findByEmail(string $email): ?User;
}
// 8. INFRASTRUCTURE : Adaptateur (Implémentation Doctrine)
namespace App\User\Infrastructure\Persistence;
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
public function existsByEmail(string $email): bool
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(u.id)')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getSingleScalarResult() > 0;
}
public function findByEmail(string $email): ?User
{
return $this->entityManager
->getRepository(User::class)
->findOneBy(['email' => $email]);
}
}
sequenceDiagram
participant Client
participant Controller
participant Handler
participant Factory
participant Email
Client->>Controller: POST /api/users<br/>{"email": "invalide", "password": "secret"}
Controller->>Handler: dispatch(command)
Handler->>Factory: create("invalide", "secret")
Factory->>Email: new Email("invalide")
Email->>Email: valider format
Email-->>Factory: ❌ InvalidEmailException
Factory-->>Handler: ❌ InvalidEmailException
Handler-->>Controller: ❌ InvalidEmailException
Controller->>Controller: capturer & transformer
Controller-->>Client: 400 Bad Request<br/>{"error": "Format email invalide"}
sequenceDiagram
participant Handler
participant Port
participant Adapter
participant DB
Handler->>Port: save(user)
Port->>Adapter: save(user)
Adapter->>DB: INSERT INTO users...
DB-->>Adapter: ❌ Violation contrainte clé dupliquée
Adapter-->>Port: ❌ UniqueConstraintViolationException
Port-->>Handler: ❌ UniqueConstraintViolationException
Handler->>Handler: capturer & envelopper
Handler-->>Handler: ❌ EmailAlreadyExistsException
// ❌ MAUVAIS : Problème N+1 Queries
public function listUsers(): array
{
$users = $this->users->findAll(); // 1 requête
foreach ($users as $user) {
$user->getOrders(); // N requêtes !
}
return $users;
}
// ✅ BON : Chargement Eager
public function listUsers(): array
{
return $this->entityManager->createQueryBuilder()
->select('u', 'o')
->from(User::class, 'u')
->leftJoin('u.orders', 'o')
->getQuery()
->getResult(); // 1 requête
}
```php
// Ajouter du cache au niveau de la couche infrastructure
final class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $decorated,
private CacheInterface $cache,
) {}
public function findByEmail(string $email): ?User
{
return $this->cache->get(
"user:email:{$email}",
fn() => $this->decorated->findByEmail($email)
);
}
}
---
## Points Clés à Retenir
1. **Transformations en Couches :** Les données se transforment à chaque frontière pour maintenir la séparation
2. **Direction Importante :** Les dépendances pointent toujours vers l'intérieur (Infrastructure → Application → Domaine)
How can I help you explore Laravel packages today?