Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Hexagonal Maker Bundle Laravel Package

ahmed-bhs/hexagonal-maker-bundle

View on GitHub
Deep Wiki
Context7

layout: default

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

  1. Les Principes SOLID
  2. Architecture Hexagonale vs Architecture en Couches
  3. Comment l'Hexagonal Respecte SOLID
  4. Les Risques d'une Mauvaise Architecture
  5. 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](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:

  • 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["📦 Enti...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
terminal42/code-quality-tools
codifyo/ts-generator-bundle
andydefer/laravel-cluster
testo/fiber
mintobit/jobqueue
a4sex/maintenance-bundle
a4sex/entity-date-update
a4sex/client-identifier
a4sex/base-utilites
a4sex/key-value-storage
a4sex/micro-status
chilldev/dependency-injection-extra
datinglibre/datinglibre-app-api
biberltd/corebundle
bricre/symfony-bundle-test
biberltd/logbundle
dominium/http-adapter-bundle
dominium/google-analytics
a4sex/auto-clean-entity
christhompsontldr/laravel-inky