ahmed-bhs/hexagonal-maker-bundle
Où cette logique doit-elle vivre : dans le Domaine (Entité/Value Object) ou dans l'Application (Handler) ?
C'est LA question la plus fréquente lors de l'implémentation d'une architecture hexagonale. La frontière entre Domaine et Application est souvent floue, conduisant à :
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TD
Start[Logique à implémenter] --> Q1{Est-ce une règle métier<br/>ou un invariant ?}
Q1 -->|Oui| Q2{Cela implique-t-il<br/>une seule entité ?}
Q1 -->|Non| Application[COUCHE APPLICATION<br/>Handler/Service]
Q2 -->|Oui| Domain1[COUCHE DOMAINE<br/>Méthode d'Entité]
Q2 -->|Non| Q3{Cela implique-t-il<br/>plusieurs entités<br/>du même agrégat ?}
Q3 -->|Oui| Domain2[COUCHE DOMAINE<br/>Méthode Racine d'Agrégat]
Q3 -->|Non| Q4{Cela implique-t-il<br/>plusieurs agrégats<br/>ou services externes ?}
Q4 -->|Oui| Application
Q4 -->|Non| Q5{Est-ce une orchestration<br/>de workflow ?}
Q5 -->|Oui| Application
Q5 -->|Non| Q6{Peut-il être exprimé<br/>sans infrastructure ?}
Q6 -->|Oui| Domain3[COUCHE DOMAINE<br/>Service de Domaine]
Q6 -->|Non| Application
style Domain1 fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px
style Domain2 fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px
style Domain3 fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px
style Application fill:#B3E5FC,stroke:#0277BD,stroke-width:3px
Si un expert métier le décrirait comme une règle, cela appartient au Domaine.
Si un expert métier le décrirait comme un processus/workflow, cela appartient à l'Application.
Règles qui DOIVENT TOUJOURS être vraies, quelle que soit la façon dont l'entité est utilisée.
// ✅ BON : Le domaine impose l'invariant
class Order
{
private OrderStatus $status;
private \DateTimeImmutable $shippedAt;
public function ship(): void
{
if ($this->status === OrderStatus::CANCELLED) {
throw new CannotShipCancelledOrderException();
}
if ($this->status === OrderStatus::SHIPPED) {
throw new OrderAlreadyShippedException();
}
$this->status = OrderStatus::SHIPPED;
$this->shippedAt = new \DateTimeImmutable();
}
}
Pourquoi dans le Domaine ? La règle "les commandes annulées ne peuvent pas être expédiées" est un invariant métier. Peu importe où Order est utilisé (CLI, API, job en arrière-plan), cette règle s'applique.
Logique gouvernant comment une entité passe d'un état à un autre.
// ✅ BON : Le domaine contrôle la machine à états
class Invoice
{
private InvoiceStatus $status;
public function markAsPaid(Money $amount): void
{
if (!$this->status->canTransitionTo(InvoiceStatus::PAID)) {
throw new InvalidInvoiceStatusTransitionException(
$this->status,
InvoiceStatus::PAID
);
}
if (!$amount->equals($this->totalAmount)) {
throw new InvalidPaymentAmountException($amount, $this->totalAmount);
}
$this->status = InvoiceStatus::PAID;
$this->paidAt = new \DateTimeImmutable();
}
}
Validation qui assure qu'une valeur a du sens dans le domaine métier.
// ✅ BON : Le domaine valide les contraintes métier
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
// Règle métier : domaine entreprise uniquement
if (!str_ends_with($value, '[@company](https://github.com/company).com')) {
throw new InvalidEmailDomainException($value);
}
}
}
Calculs purs utilisant les propriétés de l'entité.
// ✅ BON : Le domaine calcule les valeurs métier
class Order
{
private array $items; // OrderItem[]
private Money $shippingCost;
private Discount $discount;
public function calculateTotal(): Money
{
$subtotal = array_reduce(
$this->items,
fn(Money $sum, OrderItem $item) => $sum->add($item->getTotal()),
Money::zero()
);
$afterDiscount = $this->discount->apply($subtotal);
return $afterDiscount->add($this->shippingCost);
}
}
Méthodes qui expriment des opérations métier.
// ✅ BON : Le domaine exprime des concepts métier
class Subscription
{
private \DateTimeImmutable $expiresAt;
public function isExpired(): bool
{
return $this->expiresAt < new \DateTimeImmutable();
}
public function renew(Period $period): void
{
if ($this->isExpired()) {
throw new CannotRenewExpiredSubscriptionException();
}
$this->expiresAt = $period->addTo($this->expiresAt);
}
}
Coordination de plusieurs opérations en séquence.
// ✅ BON : L'application orchestre le workflow
final readonly class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users,
private EmailSenderInterface $emailSender,
private EventDispatcherInterface $eventDispatcher,
) {}
public function __invoke(RegisterUserCommand $command): void
{
// 1. Créer l'entité de domaine (logique domaine dans la factory)
$user = UserFactory::create($command->email, $command->password);
// 2. Persister (préoccupation infrastructure)
$this->users->save($user);
// 3. Envoyer l'email (préoccupation infrastructure)
$this->emailSender->send(
new WelcomeEmail($user->getEmail())
);
// 4. Dispatcher l'événement (préoccupation infrastructure)
$this->eventDispatcher->dispatch(
new UserRegisteredEvent($user->getId())
);
}
}
Pourquoi dans l'Application ? C'est un workflow : "faire ceci, puis cela, puis cela". Il coordonne plusieurs préoccupations d'infrastructure.
Opérations impliquant plusieurs racines d'agrégat.
// ✅ BON : L'application coordonne plusieurs agrégats
final readonly class TransferMoneyHandler
{
public function __construct(
private AccountRepositoryInterface $accounts,
private TransactionRepositoryInterface $transactions,
) {}
public function __invoke(TransferMoneyCommand $command): void
{
$sourceAccount = $this->accounts->findById($command->sourceAccountId);
$destinationAccount = $this->accounts->findById($command->destinationAccountId);
// Logique domaine : chaque compte valide ses propres règles
$sourceAccount->withdraw($command->amount);
$destinationAccount->deposit($command->amount);
// Logique application : coordonner la persistance des deux agrégats
$this->accounts->save($sourceAccount);
$this->accounts->save($destinationAccount);
// Logique application : créer l'enregistrement de transaction
$transaction = new Transaction(
TransactionId::generate(),
$sourceAccount->getId(),
$destinationAccount->getId(),
$command->amount
);
$this->transactions->save($transaction);
}
}
Pourquoi dans l'Application ? Modifier deux agrégats différents nécessite une coordination au niveau application.
Gérer les repositories, emails, messaging, etc.
// ✅ BON : L'application gère l'infrastructure
final readonly class PublishArticleHandler
{
public function __construct(
private ArticleRepositoryInterface $articles,
private SearchIndexInterface $searchIndex,
private CacheInterface $cache,
) {}
public function __invoke(PublishArticleCommand $command): void
{
$article = $this->articles->findById($command->articleId);
// Logique domaine
$article->publish();
// Coordination infrastructure
$this->articles->save($article);
$this->searchIndex->index($article);
$this->cache->invalidate("article:{$article->getId()}");
}
}
Gérer les transactions de base de données à travers les opérations.
// ✅ BON : L'application gère les transactions
final readonly class CheckoutOrderHandler
{
public function __construct(
private OrderRepositoryInterface $orders,
private InventoryServiceInterface $inventory,
private EntityManagerInterface $entityManager,
) {}
public function __invoke(CheckoutOrderCommand $command): void
{
$this->entityManager->beginTransaction();
try {
$order = $this->orders->findById($command->orderId);
// Logique domaine
$order->confirm();
// Opérations infrastructure dans la transaction
$this->orders->save($order);
$this->inventory->reserveStock($order->getItems());
$this->entityManager->commit();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
}
}
Valider les entrées externes et transformer en objets domaine.
// ✅ BON : L'application valide les entrées
final readonly class CreateProductHandler
{
public function __construct(
private ProductRepositoryInterface $products,
private ValidatorInterface $validator,
) {}
public function __invoke(CreateProductCommand $command): void
{
// Application : valider la structure de la commande
$errors = $this->validator->validate($command);
if (count($errors) > 0) {
throw new ValidationException($errors);
}
// Application : vérifier les contraintes métier nécessitant le repository
if ($this->products->existsByName($command->name)) {
throw new ProductNameAlreadyExistsException($command->name);
}
// Domaine : la factory contient la logique de création d'entité
$product = ProductFactory::create(
name: $command->name,
price: Money::fromCents($command->priceInCents),
stock: $command->initialStock
);
// Application : persister
$this->products->save($product);
}
}
// ❌ FAUX : Le handler contient des règles métier !
final readonly class CancelOrderHandler
{
public function __invoke(CancelOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId);
// Logique métier fuitée dans le handler !
if ($order->getStatus() === OrderStatus::SHIPPED) {
throw new CannotCancelShippedOrderException();
}
if ($order->getStatus() === OrderStatus::DELIVERED) {
throw new CannotCancelDeliveredOrderException();
}
$order->setStatus(OrderStatus::CANCELLED);
$order->setCancelledAt(new \DateTimeImmutable());
$this->orders->save($order);
}
}
Problèmes :
Order est juste un sac de données (modèle anémique)// ✅ CORRECT : L'entité impose les règles métier
class Order
{
public function cancel(): void
{
if ($this->status === OrderStatus::SHIPPED) {
throw new CannotCancelShippedOrderException();
}
if ($this->status === OrderStatus::DELIVERED) {
throw new CannotCancelDeliveredOrderException();
}
$this->status = OrderStatus::CANCELLED;
$this->cancelledAt = new \DateTimeImmutable();
}
}
// Le handler devient un orchestrateur mince
final readonly class CancelOrderHandler
{
public function __invoke(CancelOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId);
$order->cancel(); // Le domaine impose les règles
$this->orders->save($order);
$this->eventDispatcher->dispatch(new OrderCancelledEvent($order->getId()));
}
}
Bénéfices :
// ❌ FAUX : Mélange des préoccupations domaine et application
final readonly class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
// Validation d'entrée (OK dans l'application)
if (empty($command->email)) {
throw new InvalidArgumentException('Email requis');
}
// Validation métier (devrait être dans le domaine !)
if (!filter_var($command->email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException();
}
// Règle métier (devrait être dans le domaine !)
if (strlen($command->password) < 8) {
throw new PasswordTooShortException();
}
// Vérification de règle métier (OK, nécessite le repository)
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException();
}
// Création d'entité (devrait être dans une factory !)
$user = new User(
UserId::generate(),
new Email($command->email),
new HashedPassword($command->password),
false
);
$this->users->save($user);
$this->emailSender->send(new WelcomeEmail($user->getEmail()));
}
}
// Domaine : 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);
}
}
}
// Domaine : Value Object avec validation
final readonly class Password
{
public function __construct(public string $value)
{
if (strlen($value) < 8) {
throw new PasswordTooShortException();
}
}
}
// Domaine : Factory encapsule la logique de création
final class UserFactory
{
public static function create(string $email, string $password): User
{
return new User(
UserId::generate(),
new Email($email), // Valide dans le constructeur
HashedPassword::fromPlaintext($password), // Hash + valide
isActive: false
);
}
}
// Application : Orchestrateur mince
final readonly class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
// Application : vérifier l'unicité (nécessite le repository)
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException($command->email);
}
// Domaine : la factory gère la création + validation
$user = UserFactory::create($command->email, $command->password);
// Application : orchestrer l'infrastructure
$this->users->save($user);
$this->emailSender->send(new WelcomeEmail($user->getEmail()));
$this->eventDispatcher->dispatch(new UserRegisteredEvent($user->getId()));
}
}
Symptôme : Les entités sont juste des conteneurs de données avec getters/setters, toute la logique est dans les handlers.
// ❌ Entité anémique
class Order
{
private OrderStatus $status;
public function getStatus(): OrderStatus { return $this->status; }
public function setStatus(OrderStatus $status): void { $this->status = $status; }
}
// ❌ Handler avec logique métier
class ShipOrderHandler
{
public function __invoke(ShipOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId);
if ($order->getStatus() !== OrderStatus::CONFIRMED) {
throw new InvalidOrderStatusException();
}
$order->setStatus(OrderStatus::SHIPPED);
$this->orders->save($order);
}
}
Solution : Déplacer la logique métier dans les méthodes d'entité.
// ✅ Modèle de domaine riche
class Order
{
private OrderStatus $status;
public function ship(): void
{
if ($this->status !== OrderStatus::CONFIRMED) {
throw new CannotShipUnconfirmedOrderException();
}
$this->status = OrderStatus::SHIPPED;
$this->shippedAt = new \DateTimeImmutable();
}
}
// ✅ Handler mince
class ShipOrderHandler
{
public function __invoke(ShipOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId);
$order->ship(); // Le domaine gère les règles
$this->orders->save($order);
}
}
Symptôme : Les handlers contiennent des centaines de lignes, plusieurs responsabilités.
// ❌ Handler obèse avec trop de responsabilités
class ProcessOrderHandler
{
public function __invoke(ProcessOrderCommand $command): void
{
// 200 lignes de validation, calcul, orchestration...
// Règles métier mélangées avec l'infrastructure
// Difficile à tester, difficile à comprendre
}
}
Solution : Extraire la logique métier vers le domaine, garder le handler focalisé sur l'orchestration.
Symptôme : Essayer de mettre une logique nécessitant des données externes dans le domaine.
// ❌ FAUX : L'entité ne peut pas accéder au repository
class User
{
public function changeEmail(Email $newEmail, UserRepositoryInterface $users): void
{
// Ne peut pas injecter le repository dans l'entité !
if ($users->existsByEmail($newEmail)) {
throw new EmailAlreadyExistsException();
}
$this->email = $newEmail;
}
}
Solution : Mettre la vérification dans la couche application.
// ✅ CORRECT : L'application vérifie, le domaine met à jour
class ChangeUserEmailHandler
{
public function __invoke(ChangeUserEmailCommand $command): void
{
$user = $this->users->findById($command->userId);
// Application : vérification d'unicité nécessite le repository
if ($this->users->existsByEmail($command->newEmail)) {
throw new EmailAlreadyExistsException($command->newEmail);
}
// Domaine : simple mise à jour avec validation
$user->changeEmail(new Email($command->newEmail));
$this->users->save($user);
}
}
Une bonne séparation signifie des tests faciles :
// ✅ Tester la logique domaine sans aucune infrastructure
class OrderTest extends TestCase
{
public function test_cannot_ship_cancelled_order(): void
{
$order = new Order(OrderId::generate(), OrderStatus::CANCELLED);
$this->expectException(CannotShipCancelledOrderException::class);
$order->ship();
}
// Pas de base de données, pas de framework, tests PHP purs
}
// ✅ Tester l'orchestration du handler avec repository en mémoire
class ShipOrderHandlerTest extends TestCase
{
public function test_ships_order_and_dispatches_event(): void
{
$orders = new InMemoryOrderRepository(); // Fausse infrastructure
$events = new InMemoryEventDispatcher();
$handler = new ShipOrderHandler($orders, $events);
$order = new Order(OrderId::generate(), OrderStatus::CONFIRMED);
$orders->save($order);
$handler(new ShipOrderCommand($order->getId()));...
How can I help you explore Laravel packages today?