ahmed-bhs/hexagonal-maker-bundle
layout: default
L'architecture hexagonale (aussi appelée Ports and Adapters) est un pattern architectural qui vise à isoler la logique métier des préoccupations techniques (framework, base de données, API externes, etc.).
Les dépendances pointent toujours vers l'intérieur (vers le domaine)
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'16px'}}}%%
graph LR
Infra["🔌 Infrastructure<br/><small>Adapters</small>"]
App["⚙️ Application<br/><small>Use Cases</small>"]
Domain["💎 Domain<br/><small>Business Logic</small>"]
Infra ==>|"depends on"| App
App ==>|"depends on"| Domain
style Domain fill:#C8E6C9,stroke:#2E7D32,stroke-width:4px,color:#000
style App fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style Infra fill:#F8BBD0,stroke:#C2185B,stroke-width:3px,color:#000
Responsabilité: Logique métier pure, règles de gestion, invariants
Contient:
Model/ - Entités avec identité et cycle de vieValueObject/ - Objets immuables définis par leurs valeursPort/In/ - Interfaces des ports primaires (driving) - ce que l'application offrePort/Out/ - Interfaces des ports secondaires (driven) - ce dont l'application a besoinRègles strictes:
Exemple Entity:
namespace App\User\Account\Domain\Model;
final class User
{
public function __construct(
private UserId $id,
private Email $email,
private bool $isActive = false,
) {
}
// Business logic
public function activate(): void
{
if ($this->isActive) {
throw new UserAlreadyActiveException();
}
$this->isActive = true;
}
}
Exemple Value Object:
namespace App\User\Account\Domain\ValueObject;
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
}
}
Exemple Port Out (Interface - Driven/Secondary):
namespace App\User\Account\Domain\Port\Out;
/**
* Output Port - Defines what the application NEEDS from infrastructure
* Implemented by adapters in Infrastructure layer (e.g., DoctrineUserRepository)
*/
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
}
Exemple Port In (Interface - Driving/Primary):
namespace App\User\Account\Domain\Port\In;
/**
* Input Port - Defines what the application OFFERS to the outside world
* Implemented by use cases in Application layer
*/
interface CreateUserUseCaseInterface
{
public function execute(CreateUserCommand $command): void;
Responsabilité: Cas d'utilisation, orchestration des opérations métier
Contient:
Command/ - Commandes CQRS (écritures)Query/ - Requêtes CQRS (lectures)Règles:
Command:
namespace App\User\Account\Application\Register;
final readonly class RegisterCommand
{
public function __construct(
public string $email,
public string $password,
) {
}
}
Command Handler:
namespace App\User\Account\Application\Register;
use App\User\Account\Domain\Port\UserRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class RegisterCommandHandler
{
public function __construct(
private UserRepositoryInterface $repository,
private PasswordHasherInterface $hasher,
) {
}
public function __invoke(RegisterCommand $command): void
{
// Orchestration only, no business logic
$user = new User(
id: UserId::generate(),
email: new Email($command->email),
password: $this->hasher->hash($command->password),
);
$this->repository->save($user);
}
}
Responsabilité: Implémentations concrètes, détails techniques
Contient:
Persistence/ - Adapters pour la persistance (Doctrine, etc.)Messaging/ - Adapters pour la messagerieExternalAPI/ - Adapters pour les APIs externesRègles:
Adapter Doctrine:
namespace App\User\Account\Infrastructure\Persistence\Doctrine;
use App\User\Account\Domain\Model\User;
use App\User\Account\Domain\Port\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
) {
}
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);
}
}
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'15px'}}}%%
graph LR
subgraph Primary["🎮 Primary Adapters (Driving)"]
HTTP["🌐 HTTP Controller"]
CLI["⌨️ CLI Command"]
GraphQL["📊 GraphQL Resolver"]
end
subgraph Core["💎 Core - Domain + Application"]
UseCases["⚙️ Use Cases<br/><small>Handlers</small>"]
PortsIn["🔗 Port/In<br/><small>Use Case Interfaces</small>"]
PortsOut["🔗 Port/Out<br/><small>Repository Interfaces</small>"]
end
subgraph Secondary["🔌 Secondary Adapters (Driven)"]
Doctrine["🗄️ Doctrine<br/><small>Repository</small>"]
Redis["⚡ Redis<br/><small>Cache</small>"]
SMTP["📧 SMTP<br/><small>Mailer</small>"]
end
Primary ==>|"calls"| PortsIn
PortsIn -.->|"implemented by"| UseCases
UseCases ==>|"uses"| PortsOut
Secondary -.->|"🎯 implements"| PortsOut
style Core fill:#C8E6C9,stroke:#2E7D32,stroke-width:4px,color:#000
style Primary fill:#E1BEE7,stroke:#6A1B9A,stroke-width:3px,color:#000
style Secondary fill:#F8BBD0,stroke:#C2185B,stroke-width:3px,color:#000
Un Port est une interface définie dans le Domain qui représente un contrat.
Types de Ports:
Ports In (Driving/Primary) - Ce que l'application offre (Domain/Port/In/)
CreateUserUseCaseInterface, RegisterUserUseCaseInterfacePorts Out (Driven/Secondary) - Ce dont l'application a besoin (Domain/Port/Out/)
UserRepositoryInterface, EmailSenderInterfaceUn Adapter est une implémentation concrète d'un Port dans l'Infrastructure.
| Port Out | Besoin métier | Implémentations |
|---|---|---|
PricingServiceInterface |
Calculer prix final (promos, B2B) | StripePricing, CustomEngine |
TaxCalculatorInterface |
Calculer TVA selon pays/produit | TaxJarAPI, InternalTaxRules |
InventoryCheckerInterface |
Vérifier disponibilité stock | WarehouseAPI, ERPConnector |
FraudDetectionInterface |
Détecter commandes suspectes | SiftScience, InternalRules |
ShippingCostCalculatorInterface |
Calculer frais de port | Colissimo, UPS, FedEx |
LoyaltyPointsServiceInterface |
Gérer points fidélité | ZendeskLoyalty, InternalSystem |
| Port In | Cas d'usage métier | Appelé par |
|---|---|---|
PlaceOrderUseCaseInterface |
Passer une commande | Web, Mobile, API |
ApplyDiscountUseCaseInterface |
Appliquer code promo | Checkout, Support |
RequestRefundUseCaseInterface |
Demander remboursement | Client, Support |
CancelSubscriptionUseCaseInterface |
Résilier abonnement | Espace client |
// Port Out (Domain/Port/Out/)
namespace App\User\Account\Domain\Port\Out;
interface UserRepositoryInterface
{
public function save(User $user): void;
}
// Adapter 1 - Doctrine (Infrastructure)
namespace App\User\Account\Infrastructure\Persistence\Doctrine;
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
}
// Adapter 2 - In Memory (Infrastructure/Tests)
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function save(User $user): void
{
$this->users[$user->getId()->value] = $user;
}
}
| Interface | Réponse |
|---|---|
RegisterUserUseCaseInterface |
In - Ce que l'app offre |
UserRepositoryInterface |
Out - L'app a besoin de persistence |
PaymentGatewayInterface |
Out - L'app a besoin du paiement |
PlaceOrderUseCaseInterface |
In - Ce que l'app offre |
TaxCalculatorInterface |
Out - L'app a besoin du calcul TVA |
EmailSenderInterface |
Out - L'app a besoin d'envoyer des emails |
Règle simple :
*UseCaseInterface → Implémenté par Application*RepositoryInterface, *ServiceInterface → Implémenté par Infrastructure
---
## 4. CQRS Pattern
```mermaid
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
UI["🎮 UI Layer<br/><small>Controller/CLI</small>"]
subgraph Write["✍️ Write Side - Commands"]
CMD["📝 Command<br/><small>RegisterUserCommand</small>"]
CMDH["⚙️ Command Handler<br/><small>RegisterUserCommandHandler</small>"]
WriteRepo["💾 Write Repository<br/><small>Save/Update/Delete</small>"]
end
subgraph Read["📖 Read Side - Queries"]
QRY["🔍 Query<br/><small>FindUserQuery</small>"]
QRYH["⚙️ Query Handler<br/><small>FindUserQueryHandler</small>"]
ReadRepo["📚 Read Repository<br/><small>Find/List/Search</small>"]
RESP["📋 Response<br/><small>FindUserResponse</small>"]
end
UI ==>|"dispatch"| CMD
UI ==>|"dispatch"| QRY
CMD ==> CMDH
CMDH ==>|"uses"| WriteRepo
QRY ==> QRYH
QRYH ==>|"uses"| ReadRepo
QRYH ==>|"returns"| RESP
style Write fill:#FFCDD2,stroke:#C62828,stroke-width:3px,color:#000
style Read fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style UI fill:#E1BEE7,stroke:#6A1B9A,stroke-width:3px,color:#000
Caractéristiques:
voidRegisterUser, PublishArticlefinal readonly class PublishArticleCommand
{
public function __construct(
public string $articleId,
public \DateTimeImmutable $publishedAt,
) {
}
}
Caractéristiques:
ResponseFindUserById, ListArticlesfinal readonly class FindUserByIdQuery
{
public function __construct(
public string $userId,
) {
}
}
final readonly class FindUserByIdResponse
{
public function __construct(
public string $id,
public string $email,
public bool $isActive,
) {
}
}
// MAUVAIS - Retourne une valeur
class RegisterUserCommand
{
public function __invoke(RegisterCommand $cmd): User { ... }
}
// BON - Void uniquement
class RegisterUserCommandHandler
{
public function __invoke(RegisterCommand $cmd): void { ... }
}
// BON - Query retourne les données
class FindUserQueryHandler
{
public function __invoke(FindUserQuery $q): FindUserResponse { ... }
}
L'architecture hexagonale facilite grandement les tests :
graph TB
subgraph Pyramid["Pyramide des Tests"]
E2E[Tests E2E<br/>Lents - Peu nombreux<br/>Full stack avec DB]
Integration[Tests d'Intégration<br/>Moyens - Modérés<br/>Avec Symfony Kernel]
Unit[Tests Unitaires<br/>Rapides - Nombreux<br/>InMemory - Mocks]
end
subgraph Layers["Couches Testées"]
UnitTests[Domain + Application<br/>Tests unitaires<br/>InMemoryRepository]
IntTests[Handlers + Adapters<br/>Tests d'intégration<br/>DoctrineRepository]
E2ETests[UI → DB<br/>Tests E2E<br/>Parcours complet]
end
Unit -.->|teste| UnitTests
Integration -.->|teste| IntTests
E2E -.->|teste| E2ETests
style Unit fill:#90EE90,stroke:#333,stroke-width:2px
style Integration fill:#87CEEB,stroke:#333,stroke-width:2px
style E2E fill:#FFB6C1,stroke:#333,stroke-width:2px
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());
}
}
class RegisterCommandHandlerTest extends TestCase
{
public function testUserIsRegistered(): void
{
$repository = new InMemoryUserRepository();
$handler = new RegisterCommandHandler($repository);
$command = new RegisterCommand(
email: 'test@example.com',
password: 'secret',
);
$handler($command);
$this->assertCount(1, $repository->all());
}
}
src/
└── User/ # Bounded Context
└── Account/ # Module
├── Application/ # Couche Application
│ ├── Register/
│ │ ├── RegisterCommand.php
│ │ ├── RegisterCommandHandler.php
│ │ └── AccountFactory.php
│ └── Find/
│ ├── FindQuery.php
│ ├── FindQueryHandler.php
│ └── FindResponse.php
│
├── Domain/ # Couche Domain (Cœur)
│ ├── Model/
│ │ └── User.php
│ ├── ValueObject/
│ │ ├── Email.php
│ │ └── UserId.php
│ └── Port/
│ ├── In/ # Input/Driving Ports (Primary)
│ │ └── RegisterUserUseCaseInterface.php
│ └── Out/ # Output/Driven Ports (Secondary)
│ └── UserRepositoryInterface.php
│
└── Infrastructure/ # Couche Infrastructure
├── Persistence/
│ ├── Doctrine/
│ │ ├── DoctrineUserRepository.php
│ │ └── Mapping/
│ │ └── User.orm.xml
│ └── InMemory/
│ └── InMemoryUserRepository.php
└── Messaging/
└── SymfonyMessengerAdapter.php
// MAUVAIS - Dépendance à Doctrine
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class User { }
// BON - PHP pur
class User
{
public function __construct(
private UserId $id,
private Email $email,
) {
}
}
// MAUVAIS - Mutable
class Email
{
public string $value;
public function setValue(string $value): void
{
$this->value = $value;
}
}
// BON - Immutable avec readonly
final readonly class Email
{
public function __construct(
public string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException();
}
}
}
// MAUVAIS - Adapter dans le Domain
namespace App\Domain;
use Doctrine\ORM\EntityManagerInterface;
class UserService
{
public function __construct(
private EntityManagerInterface $em
) {}
}
// BON - Port (interface) dans le Domain
namespace App\Domain\Port;
interface UserRepositoryInterface
{
public function save(User $user): void;
}
namespace App\Application;
class RegisterHandler
{
public function __construct(
private UserRepositoryInterface $repository
) {}
}
final readonly class OrderFactory
{
public function __construct(
private IdGeneratorInterface $idGenerator,
private ClockInterface $clock,
) {
}
public function create(CreateOrderCommand $command): Order
{
return new Order(
id: new OrderId($this->idGenerator->generate()),
customerId: new CustomerId($command->customerId),
items: $this->createOrderItems($command->items),
createdAt: $this->clock->now(),
);
}
}
L'architecture hexagonale encourage naturellement l'utilisation de nombreux design patterns éprouvés. Cette section explore comment l'hexagonal facilite et favorise ces patterns.
graph TB
subgraph Creational["Patterns de Création (Creational)"]
Factory[Factory Pattern]
Builder[Builder Pattern]
Singleton[Singleton Pattern]
end
subgraph Structural["Patterns Structuraux (Structural)"]
Adapter[Adapter Pattern]
Repository[Repository Pattern]
DTO[DTO Pattern]
end
subgraph Behavioral["Patterns Comportementaux (Behavioral)"]
Strategy[Strategy Pattern]
Observer[Observer Pattern]
Command[Command Pattern]
end
subgraph Hexagonal["Architecture Hexagonale"]
Domain[Domain Layer]
App[Application Layer]
Infra[Infrastructure Layer]
end
Factory -.->|crée| Domain
Builder -.->|construit| Domain
Adapter -.->|implémente ports| Infra
Repository -.->|abstrait persistance| Domain
DTO -.->|transfère données| App
Strategy -.->|sélection implémentation| Infra
Command -.->|encapsule intention| App
style Creational fill:#FFD700,stroke:#333,stroke-width:2px
style Structural fill:#87CEEB,stroke:#333,stroke-width:2px
style Behavioral fill:#FFB6C1,stroke:#333,stroke-width:2px
style Hexagonal fill:#90EE90,stroke:#333,stroke-width:2px
Pourquoi l'hexagonal le favorise:
Exemple:
<?php
declare(strict_types=1);
namespace App\User\Account\Application\Register;
use App\User\Account\Domain\Model\User;
use App\User\Account\Domain\ValueObject\Email;
use App\User\Account\Domain\ValueObject\UserId;
use App\User\Account\Domain\ValueObject\HashedPassword;
use App\Shared\Domain\Service\IdGeneratorInterface;
use App\Shared\Domain\Service\PasswordHasherInterface;
/**
* Factory Pattern - Crée des entités complexes du Domain
*/
final readonly class UserFactory
{
public function __construct(
private IdGeneratorInterface $idGenerator,
private PasswordHasherInterface $passwordHasher,
) {
}
public function createFromCommand(RegisterCommand $command): User
{
return new User(
id: new UserId($this->idGenerator->generate()),
email: new Email($command->email),
password: new HashedPassword(
$this->passwordHasher->hash($command->password)
),
createdAt: new \DateTimeImmutable(),
);
}
}
Avantages dans l'hexagonal:
Utilisation:
Exemple:
<?php
declare(strict_types=1);
namespace App\Order\Domain\Builder;
use App\Order\Domain\Model\Order;
use App\Order\Domain\ValueObject\OrderId;
use App\Order\Domain\ValueObject\OrderItem;
/**
* Builder Pattern - Construction progressive d'une commande
*/
final class OrderBuilder
{
private ?OrderId $id = null;
private ?string $customerId = null;
private array $items = [];
private...
How can I help you explore Laravel packages today?