Aller au contenu

Architecture Hexagonale — Cours complet

Ports & Adapters · Sans Spring Boot · Avec Spring Boot · Java 17 · Eclipse


Sommaire


1. Pourquoi une architecture ?

1.1. Le problème du code « spaghetti »

Imaginez un projet Java démarré il y a deux ans. Au départ, tout allait bien : une classe ProduitController, une classe ProduitService, une classe ProduitRepository. Mais au fil du temps, les développeurs ont pris des raccourcis. Le controller appelle directement la base de données. Le service fait des appels HTTP vers une API externe. La logique métier est éparpillée partout.

Architecture spaghetti — tout dépend de tout

Controller ──────────────────────────────▶ Base de données
    │                                           ▲
    └──────────▶ Service ──────────────────────┘
                    │
                    └──────────▶ API externe HTTP
                    │
                    └──────────▶ Envoi d'email
                    │
                    └──────────▶ Fichier CSV

Les conséquences :

1.2. Ce que résout l’architecture hexagonale

L’architecture hexagonale, inventée par Alistair Cockburn en 2005, répond à une idée simple : le cœur métier ne doit jamais dépendre des détails techniques.

Architecture hexagonale — le domaine est protégé

  [API REST]     [CLI]     [Tests]
       │           │          │
       └───────────┴──────────┘
                   │  Ports d'entrée (interfaces)
            ┌──────▼──────┐
            │             │
            │   DOMAINE   │  ← Logique métier pure
            │  (hexagone) │  ← Aucune dépendance technique
            │             │
            └──────┬──────┘
                   │  Ports de sortie (interfaces)
       ┌───────────┴──────────┐
       │           │          │
  [MySQL]    [PostgreSQL]  [Fichier]

Le domaine est au centre. Il ne sait pas s’il est appelé par une API REST ou une ligne de commande. Il ne sait pas si les données sont stockées en MySQL ou dans un fichier CSV. C’est la technique qui s’adapte au métier, pas l’inverse.

1.3. Les bénéfices concrets

Problème classique Solution hexagonale
Tester nécessite une BDD Tests unitaires sans aucune infrastructure
Changer de BDD = réécriture Changer l’adaptateur seulement
Logique métier noyée dans Spring Domaine Java pur, zéro annotation framework
Couplage fort entre les couches Dépendances via interfaces seulement
Difficile à comprendre en équipe Structure claire et prévisible

💡 L’architecture hexagonale n’est pas réservée aux grands projets. Même une petite application gagne en clarté et testabilité. C’est une façon de penser la séparation des responsabilités.


2. L’architecture Hexagonale — Concepts fondamentaux

2.1. Les trois zones

L’architecture hexagonale découpe l’application en trois zones distinctes :

┌─────────────────────────────────────────────────────────────┐
│                    ADAPTATEURS PRIMAIRES                    │
│              (qui déclenchent l'application)                │
│                                                             │
│    [REST Controller]  [CLI]  [Tests JUnit]  [Scheduler]    │
└────────────────────────────┬────────────────────────────────┘
                             │ utilisent les
                    ┌────────▼────────┐
                    │   PORTS         │
                    │   D'ENTRÉE      │ ← interfaces Java
                    │   (Use Cases)   │
                    └────────┬────────┘
                             │ implémentés par
┌────────────────────────────▼────────────────────────────────┐
│                      LE DOMAINE                             │
│                                                             │
│   ┌──────────────┐    ┌───────────────────────────────┐   │
│   │   Entités    │    │   Services / Use Cases        │   │
│   │   (Objets    │    │   (Logique métier pure)       │   │
│   │   métier)    │    │   ← Aucune annotation Spring  │   │
│   └──────────────┘    └───────────────────────────────┘   │
│                                                             │
│   ┌──────────────────────────────────────────────────────┐ │
│   │       PORTS DE SORTIE  ← interfaces Java             │ │
│   │   (Repository, NotificationPort, EmailPort...)       │ │
│   └──────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────┘
                             │ implémentés par
                    ┌────────▼────────┐
                    │   PORTS         │
                    │   DE SORTIE     │
                    └────────┬────────┘
                             │
┌────────────────────────────▼────────────────────────────────┐
│                   ADAPTATEURS SECONDAIRES                   │
│             (que l'application pilote)                      │
│                                                             │
│  [JPA Repository]  [API HTTP Client]  [SMTP]  [CSV File]  │
└─────────────────────────────────────────────────────────────┘

2.2. Ports et Adaptateurs — La terminologie

Port d’entrée (Driving Port / Primary Port) : Interface Java qui expose ce que le domaine peut faire. C’est le contrat que les acteurs extérieurs utilisent pour déclencher l’application.

Port de sortie (Driven Port / Secondary Port) : Interface Java qui définit ce dont le domaine a besoin de l’extérieur (stocker des données, envoyer des emails, appeler une API…).

Adaptateur primaire (Driving Adapter) : Implémentation qui traduit une requête externe (HTTP, CLI, test) en appel sur un port d’entrée.

Adaptateur secondaire (Driven Adapter) : Implémentation d’un port de sortie. Fait le pont entre le domaine et la technique (JPA, HTTP client, SMTP…).

La règle de dépendance : les dépendances ne pointent que vers l’intérieur (vers le domaine). Le domaine ne connaît jamais les adaptateurs.

2.3. Règle de la dépendance inversée

 INTERDIT — Le domaine importe un adaptateur
import fr.formation.infrastructure.jpa.ProduitJpaRepository;

class ProduitService {
    private ProduitJpaRepository repo; // ← Dépend d'un détail technique !
}

 AUTORISÉ — Le domaine définit son propre contrat (port)
// Dans le domaine :
interface ProduitRepository {            // ← Port de sortie
    Produit sauvegarder(Produit p);
    Optional<Produit> trouverParId(String id);
}

class ProduitService {
    private ProduitRepository repo;      // ← Dépend d'une abstraction
}

// Dans l'infrastructure :
class ProduitJpaAdapter implements ProduitRepository { // ← S'adapte au domaine
    // Implémentation JPA ici
}

2.4. Structure de packages recommandée

fr.formation.monapp/
│
├── domain/                          ← LE DOMAINE (zéro dépendance externe)
│   ├── model/                       ← Entités métier
│   │   ├── Produit.java
│   │   └── Categorie.java
│   ├── port/
│   │   ├── in/                      ← Ports d'entrée (use cases)
│   │   │   ├── CreerProduitUseCase.java
│   │   │   └── RechercherProduitUseCase.java
│   │   └── out/                     ← Ports de sortie
│   │       ├── ProduitRepository.java
│   │       └── NotificationPort.java
│   └── service/                     ← Implémentation des use cases
│       └── ProduitService.java
│
├── application/                     ← Orchestration (optionnel, parfois fusionné avec domain)
│   └── ProduitApplicationService.java
│
└── infrastructure/                  ← ADAPTATEURS (dépendent du domaine)
    ├── in/                          ← Adaptateurs primaires
    │   ├── web/
    │   │   └── ProduitController.java
    │   └── cli/
    │       └── ProduitCli.java
    └── out/                         ← Adaptateurs secondaires
        ├── persistence/
        │   ├── ProduitJpaAdapter.java
        │   └── ProduitJpaEntity.java
        ├── http/
        │   └── PrixApiAdapter.java
        └── notification/
            └── EmailAdapter.java

3. Le Domaine — Cœur de l’application

3.1. Les entités métier

Les entités du domaine sont de simples classes Java — pas d’annotations Spring, pas d’annotations JPA, pas de dépendances externes. Ce sont des objets qui représentent les concepts métier.

// domain/model/Livre.java
package fr.formation.bibliotheque.domain.model;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Objects;

/**
 * Entité du domaine — représente un livre dans la bibliothèque.
 * AUCUNE annotation technique (JPA, Spring, Jackson...).
 * C'est du Java pur qui exprime le métier.
 */
public class Livre {

    private final String  isbn;       // Identifiant métier naturel
    private String        titre;
    private String        auteur;
    private BigDecimal    prix;
    private int           stock;
    private LocalDate     datePublication;
    private boolean       disponible;

    // ── Constructeur avec validation métier ─────────────────────────────────
    public Livre(String isbn, String titre, String auteur,
                 BigDecimal prix, int stock) {
        validerIsbn(isbn);
        validerTitre(titre);
        validerPrix(prix);
        validerStock(stock);

        this.isbn            = isbn;
        this.titre           = titre;
        this.auteur          = auteur;
        this.prix            = prix;
        this.stock           = stock;
        this.disponible      = stock > 0;
    }

    // ── Règles métier dans l'entité ─────────────────────────────────────────
    /**
     * Emprunter un livre — règle métier : stock doit être > 0.
     * Retourne une nouvelle instance (style immutable).
     */
    public Livre emprunter() {
        if (stock <= 0) {
            throw new IllegalStateException(
                "Impossible d'emprunter '" + titre + "' : aucun exemplaire disponible.");
        }
        // On modifie et retourne this (style mutable simple pour ce cours)
        this.stock--;
        this.disponible = this.stock > 0;
        return this;
    }

    /**
     * Retourner un livre — augmente le stock.
     */
    public Livre retourner() {
        this.stock++;
        this.disponible = true;
        return this;
    }

    /**
     * Appliquer une remise sur le prix.
     * Règle métier : remise entre 0% et 50%.
     */
    public Livre appliquerRemise(int pourcentage) {
        if (pourcentage < 0 || pourcentage > 50) {
            throw new IllegalArgumentException(
                "La remise doit être entre 0 et 50%. Reçu : " + pourcentage + "%");
        }
        this.prix = prix.multiply(
            BigDecimal.ONE.subtract(
                BigDecimal.valueOf(pourcentage).divide(BigDecimal.valueOf(100))
            )
        );
        return this;
    }

    public boolean estDisponible() {
        return disponible && stock > 0;
    }

    // ── Validations métier ──────────────────────────────────────────────────
    private static void validerIsbn(String isbn) {
        if (isbn == null || isbn.isBlank())
            throw new IllegalArgumentException("L'ISBN ne peut pas être vide.");
        if (isbn.replaceAll("[\\s-]", "").length() != 13)
            throw new IllegalArgumentException("L'ISBN doit contenir 13 chiffres : " + isbn);
    }

    private static void validerTitre(String titre) {
        if (titre == null || titre.isBlank())
            throw new IllegalArgumentException("Le titre ne peut pas être vide.");
    }

    private static void validerPrix(BigDecimal prix) {
        if (prix == null || prix.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Le prix ne peut pas être négatif.");
    }

    private static void validerStock(int stock) {
        if (stock < 0)
            throw new IllegalArgumentException("Le stock ne peut pas être négatif.");
    }

    // ── Getters (pas de setters — on passe par des méthodes métier) ─────────
    public String     getIsbn()             { return isbn; }
    public String     getTitre()            { return titre; }
    public String     getAuteur()           { return auteur; }
    public BigDecimal getPrix()             { return prix; }
    public int        getStock()            { return stock; }
    public LocalDate  getDatePublication()  { return datePublication; }
    public boolean    isDisponible()        { return disponible; }

    public void setDatePublication(LocalDate d) { this.datePublication = d; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Livre)) return false;
        return Objects.equals(isbn, ((Livre) o).isbn);
    }

    @Override
    public int hashCode() { return Objects.hash(isbn); }

    @Override
    public String toString() {
        return String.format("Livre{isbn='%s', titre='%s', auteur='%s', prix=%s, stock=%d}",
            isbn, titre, auteur, prix, stock);
    }
}

3.2. Les Value Objects

Les Value Objects sont des objets immuables définis par leur valeur, pas par leur identité.

// domain/model/Isbn.java
package fr.formation.bibliotheque.domain.model;

import java.util.Objects;

/**
 * Value Object pour l'ISBN.
 * Immuable, validé à la création, exprime un concept métier.
 */
public final class Isbn {

    private final String valeur;

    public Isbn(String valeur) {
        if (valeur == null || valeur.isBlank())
            throw new IllegalArgumentException("L'ISBN ne peut pas être vide.");
        String normalise = valeur.replaceAll("[\\s-]", "");
        if (normalise.length() != 13 || !normalise.matches("\\d+"))
            throw new IllegalArgumentException("ISBN invalide : " + valeur);
        this.valeur = normalise;
    }

    public String getValeur() { return valeur; }

    // Formaté avec tirets : 978-2-07-036822-8
    public String formater() {
        return valeur.substring(0, 3) + "-" +
               valeur.substring(3, 4) + "-" +
               valeur.substring(4, 6) + "-" +
               valeur.substring(6, 11) + "-" +
               valeur.substring(11);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Isbn)) return false;
        return Objects.equals(valeur, ((Isbn) o).valeur);
    }

    @Override
    public int hashCode() { return Objects.hash(valeur); }

    @Override
    public String toString() { return valeur; }
}

3.3. Les exceptions du domaine

// domain/exception/LivreIntrouvableException.java
package fr.formation.bibliotheque.domain.exception;

/**
 * Exception métier — le livre n'existe pas dans le catalogue.
 * Ce sont DES exceptions du domaine — pas des exceptions techniques.
 */
public class LivreIntrouvableException extends RuntimeException {
    private final String isbn;

    public LivreIntrouvableException(String isbn) {
        super("Aucun livre trouvé avec l'ISBN : " + isbn);
        this.isbn = isbn;
    }

    public String getIsbn() { return isbn; }
}
// domain/exception/StockInsuffisantException.java
package fr.formation.bibliotheque.domain.exception;

public class StockInsuffisantException extends RuntimeException {
    public StockInsuffisantException(String titre) {
        super("Stock insuffisant pour le livre : " + titre);
    }
}

4. Les Ports — Contrats d’entrée et de sortie

4.1. Ports d’entrée — Ce que l’application peut faire

Les ports d’entrée sont des interfaces Java qui définissent les cas d’usage (use cases) de l’application. Ils constituent l’API publique du domaine.

// domain/port/in/CreerLivreUseCase.java
package fr.formation.bibliotheque.domain.port.in;

import fr.formation.bibliotheque.domain.model.Livre;

/**
 * Port d'entrée — cas d'usage : créer un nouveau livre.
 * Une interface par cas d'usage = Single Responsibility claire.
 */
public interface CreerLivreUseCase {

    /**
     * Crée un nouveau livre dans le catalogue.
     * @param commande Données nécessaires pour créer le livre
     * @return Le livre créé avec son identifiant
     * @throws IllegalArgumentException Si les données sont invalides
     */
    Livre creerLivre(CreerLivreCommande commande);

    /**
     * Commande (Command pattern) — données nécessaires au use case.
     * C'est un DTO interne au domaine.
     */
    record CreerLivreCommande(
        String isbn,
        String titre,
        String auteur,
        java.math.BigDecimal prix,
        int stockInitial
    ) {
        // Validation dans le record
        public CreerLivreCommande {
            if (titre == null || titre.isBlank())
                throw new IllegalArgumentException("Le titre est obligatoire.");
            if (prix == null || prix.signum() < 0)
                throw new IllegalArgumentException("Le prix ne peut pas être négatif.");
        }
    }
}
// domain/port/in/ConsulterLivreUseCase.java
package fr.formation.bibliotheque.domain.port.in;

import fr.formation.bibliotheque.domain.model.Livre;
import java.util.List;
import java.util.Optional;

/**
 * Port d'entrée — cas d'usage : consulter les livres.
 */
public interface ConsulterLivreUseCase {

    Optional<Livre> trouverParIsbn(String isbn);

    List<Livre> trouverTous();

    List<Livre> trouverDisponibles();

    List<Livre> rechercherParAuteur(String auteur);
}
// domain/port/in/EmprunterLivreUseCase.java
package fr.formation.bibliotheque.domain.port.in;

import fr.formation.bibliotheque.domain.model.Livre;

/**
 * Port d'entrée — cas d'usage : emprunter et retourner un livre.
 */
public interface EmprunterLivreUseCase {

    Livre emprunter(String isbn, String membreId);

    Livre retourner(String isbn, String membreId);
}

4.2. Ports de sortie — Ce dont l’application a besoin

Les ports de sortie définissent les dépendances du domaine vers l’extérieur. Ils sont implémentés par les adaptateurs secondaires.

// domain/port/out/LivreRepository.java
package fr.formation.bibliotheque.domain.port.out;

import fr.formation.bibliotheque.domain.model.Livre;
import java.util.List;
import java.util.Optional;

/**
 * Port de sortie — contrat de persistance des livres.
 * Le domaine définit CE DONT IL A BESOIN.
 * L'infrastructure décide COMMENT le faire.
 */
public interface LivreRepository {

    Livre sauvegarder(Livre livre);

    Optional<Livre> trouverParIsbn(String isbn);

    List<Livre> trouverTous();

    List<Livre> trouverDisponibles();

    List<Livre> trouverParAuteur(String auteur);

    boolean existeParIsbn(String isbn);

    void supprimer(String isbn);
}
// domain/port/out/NotificationPort.java
package fr.formation.bibliotheque.domain.port.out;

/**
 * Port de sortie — contrat de notification.
 * Le domaine ne sait pas si c'est un email, un SMS ou une notification push.
 */
public interface NotificationPort {

    void notifierDisponibilite(String isbn, String titre, String membreId);

    void notifierRetardRetour(String isbn, String membreId, int joursRetard);
}
// domain/port/out/JournalPort.java
package fr.formation.bibliotheque.domain.port.out;

/**
 * Port de sortie — journalisation des événements métier.
 */
public interface JournalPort {

    void enregistrerEmprunt(String isbn, String membreId);

    void enregistrerRetour(String isbn, String membreId);
}

5. Les Adaptateurs — Connecter au monde réel

5.1. Adaptateur primaire — Interface en ligne de commande

// infrastructure/in/cli/BibliothequeCliAdapter.java
package fr.formation.bibliotheque.infrastructure.in.cli;

import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.in.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Scanner;

/**
 * Adaptateur primaire — interface ligne de commande.
 * Traduit les commandes CLI en appels sur les ports d'entrée.
 * NE CONTIENT PAS de logique métier.
 */
public class BibliothequeCliAdapter {

    private final CreerLivreUseCase    creerLivreUseCase;
    private final ConsulterLivreUseCase consulterLivreUseCase;
    private final EmprunterLivreUseCase emprunterLivreUseCase;
    private final Scanner scanner = new Scanner(System.in);

    // ✅ Injection par constructeur — le domaine est passé de l'extérieur
    public BibliothequeCliAdapter(
            CreerLivreUseCase creerLivreUseCase,
            ConsulterLivreUseCase consulterLivreUseCase,
            EmprunterLivreUseCase emprunterLivreUseCase) {
        this.creerLivreUseCase    = creerLivreUseCase;
        this.consulterLivreUseCase = consulterLivreUseCase;
        this.emprunterLivreUseCase = emprunterLivreUseCase;
    }

    public void demarrer() {
        System.out.println("╔════════════════════════════════╗");
        System.out.println("║   📚 Bibliothèque — CLI        ║");
        System.out.println("╚════════════════════════════════╝");

        boolean continuer = true;
        while (continuer) {
            afficherMenu();
            String choix = scanner.nextLine().trim();
            try {
                switch (choix) {
                    case "1" -> ajouterLivre();
                    case "2" -> afficherTousLesLivres();
                    case "3" -> afficherDisponibles();
                    case "4" -> emprunterLivre();
                    case "5" -> retournerLivre();
                    case "6" -> continuer = false;
                    default  -> System.out.println("⚠️ Choix invalide.");
                }
            } catch (Exception e) {
                System.err.println("❌ " + e.getMessage());
            }
        }
        System.out.println("Au revoir ! 👋");
        scanner.close();
    }

    private void afficherMenu() {
        System.out.println("\n── Menu ─────────────────────────");
        System.out.println("1. Ajouter un livre");
        System.out.println("2. Lister tous les livres");
        System.out.println("3. Livres disponibles");
        System.out.println("4. Emprunter un livre");
        System.out.println("5. Retourner un livre");
        System.out.println("6. Quitter");
        System.out.print("Votre choix : ");
    }

    private void ajouterLivre() {
        System.out.print("ISBN (13 chiffres) : ");
        String isbn = scanner.nextLine().trim();
        System.out.print("Titre : ");
        String titre = scanner.nextLine().trim();
        System.out.print("Auteur : ");
        String auteur = scanner.nextLine().trim();
        System.out.print("Prix (€) : ");
        BigDecimal prix = new BigDecimal(scanner.nextLine().trim());
        System.out.print("Stock initial : ");
        int stock = Integer.parseInt(scanner.nextLine().trim());

        Livre livre = creerLivreUseCase.creerLivre(
            new CreerLivreUseCase.CreerLivreCommande(isbn, titre, auteur, prix, stock)
        );
        System.out.println("✅ Livre ajouté : " + livre.getTitre());
    }

    private void afficherTousLesLivres() {
        List<Livre> livres = consulterLivreUseCase.trouverTous();
        if (livres.isEmpty()) {
            System.out.println("  Aucun livre dans le catalogue.");
            return;
        }
        System.out.println("\n📚 Catalogue (" + livres.size() + " livres) :");
        livres.forEach(l -> System.out.printf(
            "  %-15s %-35s %-20s %6.2f€  stock:%d%n",
            l.getIsbn().substring(0, Math.min(13, l.getIsbn().length())),
            l.getTitre(), l.getAuteur(), l.getPrix(), l.getStock()));
    }

    private void afficherDisponibles() {
        List<Livre> disponibles = consulterLivreUseCase.trouverDisponibles();
        System.out.println("\n✅ Livres disponibles (" + disponibles.size() + ") :");
        disponibles.forEach(l -> System.out.println(
            "  - " + l.getTitre() + " (" + l.getStock() + " ex.)"));
    }

    private void emprunterLivre() {
        System.out.print("ISBN du livre : ");
        String isbn = scanner.nextLine().trim();
        System.out.print("Votre identifiant membre : ");
        String membreId = scanner.nextLine().trim();
        Livre livre = emprunterLivreUseCase.emprunter(isbn, membreId);
        System.out.println("✅ Emprunt confirmé : " + livre.getTitre() +
            " (stock restant : " + livre.getStock() + ")");
    }

    private void retournerLivre() {
        System.out.print("ISBN du livre à retourner : ");
        String isbn = scanner.nextLine().trim();
        System.out.print("Votre identifiant membre : ");
        String membreId = scanner.nextLine().trim();
        Livre livre = emprunterLivreUseCase.retourner(isbn, membreId);
        System.out.println("✅ Retour enregistré : " + livre.getTitre());
    }
}

5.2. Adaptateur secondaire — Persistance en mémoire

// infrastructure/out/persistence/InMemoryLivreRepository.java
package fr.formation.bibliotheque.infrastructure.out.persistence;

import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.out.LivreRepository;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/**
 * Adaptateur secondaire — persistance en mémoire (Map).
 * Implémente le port LivreRepository défini dans le DOMAINE.
 * Utile pour les tests et le développement initial.
 */
public class InMemoryLivreRepository implements LivreRepository {

    // Stockage simple : Map ISBN → Livre
    private final Map<String, Livre> stockage = new ConcurrentHashMap<>();

    @Override
    public Livre sauvegarder(Livre livre) {
        stockage.put(livre.getIsbn(), livre);
        return livre;
    }

    @Override
    public Optional<Livre> trouverParIsbn(String isbn) {
        return Optional.ofNullable(stockage.get(isbn));
    }

    @Override
    public List<Livre> trouverTous() {
        return new ArrayList<>(stockage.values());
    }

    @Override
    public List<Livre> trouverDisponibles() {
        return stockage.values().stream()
            .filter(Livre::estDisponible)
            .collect(Collectors.toList());
    }

    @Override
    public List<Livre> trouverParAuteur(String auteur) {
        return stockage.values().stream()
            .filter(l -> l.getAuteur().toLowerCase()
                           .contains(auteur.toLowerCase()))
            .collect(Collectors.toList());
    }

    @Override
    public boolean existeParIsbn(String isbn) {
        return stockage.containsKey(isbn);
    }

    @Override
    public void supprimer(String isbn) {
        stockage.remove(isbn);
    }

    // Méthode utilitaire pour les tests
    public void vider() { stockage.clear(); }
    public int taille() { return stockage.size(); }
}

5.3. Adaptateur secondaire — Notification console

// infrastructure/out/notification/ConsoleNotificationAdapter.java
package fr.formation.bibliotheque.infrastructure.out.notification;

import fr.formation.bibliotheque.domain.port.out.NotificationPort;

/**
 * Adaptateur secondaire — notifications via la console.
 * En production, on remplacerait ceci par un adaptateur email/SMS
 * SANS TOUCHER AU DOMAINE.
 */
public class ConsoleNotificationAdapter implements NotificationPort {

    @Override
    public void notifierDisponibilite(String isbn, String titre, String membreId) {
        System.out.printf("[NOTIF] 📬 Membre %s : le livre '%s' (ISBN %s) est disponible !%n",
            membreId, titre, isbn);
    }

    @Override
    public void notifierRetardRetour(String isbn, String membreId, int joursRetard) {
        System.out.printf("[NOTIF] ⚠️ Membre %s : retard de %d jour(s) pour ISBN %s%n",
            membreId, joursRetard, isbn);
    }
}

6. Hexagonale sans Spring Boot — Projet complet

6.1. Les services du domaine

// domain/service/LivreService.java
package fr.formation.bibliotheque.domain.service;

import fr.formation.bibliotheque.domain.exception.LivreIntrouvableException;
import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.in.*;
import fr.formation.bibliotheque.domain.port.out.*;
import java.util.List;
import java.util.Optional;

/**
 * Service du domaine — implémente les use cases.
 * Contient TOUTE la logique métier.
 * Dépend uniquement d'interfaces (ports) — jamais d'implémentations.
 *
 * ✅ Aucune annotation Spring
 * ✅ Aucune import JPA
 * ✅ Aucune import HTTP
 * ✅ Java pur, testable unitairement
 */
public class LivreService
        implements CreerLivreUseCase,
                   ConsulterLivreUseCase,
                   EmprunterLivreUseCase {

    private final LivreRepository  livreRepository;
    private final NotificationPort  notificationPort;
    private final JournalPort       journalPort;

    // ✅ Injection par constructeur — les dépendances sont des INTERFACES
    public LivreService(LivreRepository livreRepository,
                        NotificationPort notificationPort,
                        JournalPort journalPort) {
        this.livreRepository = livreRepository;
        this.notificationPort = notificationPort;
        this.journalPort      = journalPort;
    }

    // ── CreerLivreUseCase ─────────────────────────────────────────────────────
    @Override
    public Livre creerLivre(CreerLivreCommande commande) {
        if (livreRepository.existeParIsbn(commande.isbn())) {
            throw new IllegalArgumentException(
                "Un livre avec l'ISBN " + commande.isbn() + " existe déjà.");
        }
        Livre livre = new Livre(
            commande.isbn(), commande.titre(),
            commande.auteur(), commande.prix(),
            commande.stockInitial()
        );
        return livreRepository.sauvegarder(livre);
    }

    // ── ConsulterLivreUseCase ─────────────────────────────────────────────────
    @Override
    public Optional<Livre> trouverParIsbn(String isbn) {
        return livreRepository.trouverParIsbn(isbn);
    }

    @Override
    public List<Livre> trouverTous() {
        return livreRepository.trouverTous();
    }

    @Override
    public List<Livre> trouverDisponibles() {
        return livreRepository.trouverDisponibles();
    }

    @Override
    public List<Livre> rechercherParAuteur(String auteur) {
        return livreRepository.trouverParAuteur(auteur);
    }

    // ── EmprunterLivreUseCase ─────────────────────────────────────────────────
    @Override
    public Livre emprunter(String isbn, String membreId) {
        Livre livre = livreRepository.trouverParIsbn(isbn)
            .orElseThrow(() -> new LivreIntrouvableException(isbn));

        livre.emprunter();  // Règle métier dans l'entité
        livreRepository.sauvegarder(livre);
        journalPort.enregistrerEmprunt(isbn, membreId);

        return livre;
    }

    @Override
    public Livre retourner(String isbn, String membreId) {
        Livre livre = livreRepository.trouverParIsbn(isbn)
            .orElseThrow(() -> new LivreIntrouvableException(isbn));

        livre.retourner();  // Règle métier dans l'entité
        livreRepository.sauvegarder(livre);
        journalPort.enregistrerRetour(isbn, membreId);

        // Notifier si le livre était en attente
        if (livre.getStock() == 1) {
            notificationPort.notifierDisponibilite(isbn, livre.getTitre(), "LISTE_ATTENTE");
        }

        return livre;
    }
}

6.2. L’assemblage — La configuration de l’application

Sans Spring Boot, on assemble manuellement les dépendances dans une classe de configuration ou directement dans le main().

// infrastructure/config/ApplicationConfig.java
package fr.formation.bibliotheque.infrastructure.config;

import fr.formation.bibliotheque.domain.port.in.*;
import fr.formation.bibliotheque.domain.port.out.*;
import fr.formation.bibliotheque.domain.service.LivreService;
import fr.formation.bibliotheque.infrastructure.in.cli.BibliothequeCliAdapter;
import fr.formation.bibliotheque.infrastructure.out.journal.ConsoleJournalAdapter;
import fr.formation.bibliotheque.infrastructure.out.notification.ConsoleNotificationAdapter;
import fr.formation.bibliotheque.infrastructure.out.persistence.InMemoryLivreRepository;

/**
 * Assemblage manuel de l'application — le "câblage" des dépendances.
 * Sans framework d'injection de dépendances, on fait cela à la main.
 * C'est l'équivalent du contexte Spring, mais en Java pur.
 *
 * On voit ici que SEULE cette classe a le droit d'avoir des imports
 * à la fois du domaine ET de l'infrastructure.
 */
public class ApplicationConfig {

    // ── Adaptateurs secondaires (out) ─────────────────────────────────────────
    private final LivreRepository   livreRepository   = new InMemoryLivreRepository();
    private final NotificationPort   notificationPort  = new ConsoleNotificationAdapter();
    private final JournalPort        journalPort       = new ConsoleJournalAdapter();

    // ── Service du domaine ───────────────────────────────────────────────────
    private final LivreService livreService = new LivreService(
        livreRepository, notificationPort, journalPort
    );

    // ── Adaptateur primaire (in) ─────────────────────────────────────────────
    private final BibliothequeCliAdapter cliAdapter = new BibliothequeCliAdapter(
        livreService,   // implémente CreerLivreUseCase
        livreService,   // implémente ConsulterLivreUseCase
        livreService    // implémente EmprunterLivreUseCase
    );

    public BibliothequeCliAdapter getCliAdapter() { return cliAdapter; }
    public LivreService getLivreService()         { return livreService; }
    public LivreRepository getLivreRepository()   { return livreRepository; }
}
// Main.java (ou BibliothequeApp.java)
package fr.formation.bibliotheque;

import fr.formation.bibliotheque.infrastructure.config.ApplicationConfig;

/**
 * Point d'entrée de l'application — version sans Spring Boot.
 */
public class Main {

    public static void main(String[] args) {
        // 1. Assembler l'application
        ApplicationConfig config = new ApplicationConfig();

        // 2. Pré-remplir avec des données de démonstration
        initialiserDonnees(config);

        // 3. Démarrer l'interface CLI
        config.getCliAdapter().demarrer();
    }

    private static void initialiserDonnees(ApplicationConfig config) {
        var repo = config.getLivreRepository();
        var svc  = config.getLivreService();

        // Données de démonstration
        svc.creerLivre(new fr.formation.bibliotheque.domain.port.in
            .CreerLivreUseCase.CreerLivreCommande(
                "9782070368228", "L'Étranger", "Albert Camus",
                new java.math.BigDecimal("8.50"), 3));
        svc.creerLivre(new fr.formation.bibliotheque.domain.port.in
            .CreerLivreUseCase.CreerLivreCommande(
                "9782070360024", "Les Misérables", "Victor Hugo",
                new java.math.BigDecimal("12.00"), 2));
        svc.creerLivre(new fr.formation.bibliotheque.domain.port.in
            .CreerLivreUseCase.CreerLivreCommande(
                "9782070413119", "Le Petit Prince", "Antoine de Saint-Exupéry",
                new java.math.BigDecimal("6.90"), 5));
    }
}

6.3. Structure complète du projet sans Spring Boot

bibliotheque-hexagonale/
├── pom.xml
└── src/
    ├── main/java/fr/formation/bibliotheque/
    │   ├── Main.java
    │   ├── domain/
    │   │   ├── model/
    │   │   │   ├── Livre.java
    │   │   │   └── Isbn.java
    │   │   ├── exception/
    │   │   │   ├── LivreIntrouvableException.java
    │   │   │   └── StockInsuffisantException.java
    │   │   ├── port/
    │   │   │   ├── in/
    │   │   │   │   ├── CreerLivreUseCase.java
    │   │   │   │   ├── ConsulterLivreUseCase.java
    │   │   │   │   └── EmprunterLivreUseCase.java
    │   │   │   └── out/
    │   │   │       ├── LivreRepository.java
    │   │   │       ├── NotificationPort.java
    │   │   │       └── JournalPort.java
    │   │   └── service/
    │   │       └── LivreService.java
    │   └── infrastructure/
    │       ├── config/
    │       │   └── ApplicationConfig.java
    │       ├── in/
    │       │   └── cli/
    │       │       └── BibliothequeCliAdapter.java
    │       └── out/
    │           ├── persistence/
    │           │   └── InMemoryLivreRepository.java
    │           ├── notification/
    │           │   └── ConsoleNotificationAdapter.java
    │           └── journal/
    │               └── ConsoleJournalAdapter.java
    └── test/java/fr/formation/bibliotheque/
        ├── domain/
        │   ├── model/
        │   │   └── LivreTest.java
        │   └── service/
        │       └── LivreServiceTest.java
        └── infrastructure/
            └── persistence/
                └── InMemoryLivreRepositoryTest.java

7. Hexagonale avec Spring Boot

7.1. Spring Boot dans l’architecture hexagonale

Spring Boot n’entre que dans la couche infrastructure. Le domaine reste identique — pas une seule annotation Spring dans domain/.

Sans Spring Boot          Avec Spring Boot
─────────────────         ──────────────────────────────
ApplicationConfig    →    @Configuration + @Bean
new Service()        →    @Service + injection automatique
new CliAdapter()     →    @RestController (adaptateur HTTP)
new JpaAdapter()     →    @Repository (adaptateur JPA)

7.2. pom.xml Spring Boot

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.3</version>
    </parent>

    <groupId>fr.formation</groupId>
    <artifactId>bibliotheque-hexagonale-spring</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Spring Web pour le contrôleur REST -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Data JPA pour la persistance -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- H2 pour les tests et le développement -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- Tests -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

7.3. Le domaine — Identique !

Le domaine (Livre, LivreService, LivreRepository…) est exactement le même qu’en version sans Spring Boot. C’est la force de l’architecture hexagonale.

💡 Vous pouvez copier-coller le package domain/ d’un projet à l’autre sans modification. C’est la définition de la portabilité.

7.4. Configuration Spring — Remplacement de ApplicationConfig

// infrastructure/config/BeanConfiguration.java
package fr.formation.bibliotheque.infrastructure.config;

import fr.formation.bibliotheque.domain.port.out.*;
import fr.formation.bibliotheque.domain.service.LivreService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Configuration Spring — remplace ApplicationConfig.
 * Spring gère le câblage des dépendances via l'injection automatique.
 */
@Configuration
public class BeanConfiguration {

    /**
     * Déclare le service du domaine comme bean Spring.
     * Spring injecte automatiquement les implémentations des ports.
     */
    @Bean
    public LivreService livreService(
            LivreRepository livreRepository,
            NotificationPort notificationPort,
            JournalPort journalPort) {
        return new LivreService(livreRepository, notificationPort, journalPort);
    }
}

7.5. Adaptateur secondaire — JPA

// infrastructure/out/persistence/LivreJpaEntity.java
package fr.formation.bibliotheque.infrastructure.out.persistence;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;

/**
 * Entité JPA — PAS une entité du domaine !
 * C'est la représentation technique de la table SQL.
 * Vit dans l'infrastructure, jamais dans le domaine.
 */
@Entity
@Table(name = "livre")
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LivreJpaEntity {

    @Id
    @Column(nullable = false, length = 13)
    private String isbn;

    @Column(nullable = false, length = 200)
    private String titre;

    @Column(nullable = false, length = 150)
    private String auteur;

    @Column(nullable = false, precision = 8, scale = 2)
    private BigDecimal prix;

    @Column(nullable = false)
    private int stock;

    @Column(nullable = false)
    private boolean disponible;
}
// infrastructure/out/persistence/SpringDataLivreRepository.java
package fr.formation.bibliotheque.infrastructure.out.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

/**
 * Repository Spring Data JPA interne à l'infrastructure.
 * PAS visible du domaine.
 */
interface SpringDataLivreRepository extends JpaRepository<LivreJpaEntity, String> {

    List<LivreJpaEntity> findByDisponibleTrue();

    List<LivreJpaEntity> findByAuteurContainingIgnoreCase(String auteur);
}
// infrastructure/out/persistence/LivreJpaAdapter.java
package fr.formation.bibliotheque.infrastructure.out.persistence;

import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.out.LivreRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

/**
 * Adaptateur secondaire JPA — implémente le port LivreRepository du domaine.
 * Fait le mapping entre les entités du domaine et les entités JPA.
 *
 * @Component : Spring le détecte et l'injecte là où LivreRepository est requis.
 */
@Component
@RequiredArgsConstructor
public class LivreJpaAdapter implements LivreRepository {

    private final SpringDataLivreRepository springRepo;

    @Override
    public Livre sauvegarder(Livre livre) {
        LivreJpaEntity entity = versEntity(livre);
        springRepo.save(entity);
        return livre;
    }

    @Override
    public Optional<Livre> trouverParIsbn(String isbn) {
        return springRepo.findById(isbn).map(this::versDomaine);
    }

    @Override
    public List<Livre> trouverTous() {
        return springRepo.findAll().stream()
            .map(this::versDomaine)
            .collect(Collectors.toList());
    }

    @Override
    public List<Livre> trouverDisponibles() {
        return springRepo.findByDisponibleTrue().stream()
            .map(this::versDomaine)
            .collect(Collectors.toList());
    }

    @Override
    public List<Livre> trouverParAuteur(String auteur) {
        return springRepo.findByAuteurContainingIgnoreCase(auteur).stream()
            .map(this::versDomaine)
            .collect(Collectors.toList());
    }

    @Override
    public boolean existeParIsbn(String isbn) {
        return springRepo.existsById(isbn);
    }

    @Override
    public void supprimer(String isbn) {
        springRepo.deleteById(isbn);
    }

    // ── Mapping Domaine ↔ JPA ─────────────────────────────────────────────────

    private LivreJpaEntity versEntity(Livre livre) {
        return LivreJpaEntity.builder()
            .isbn(livre.getIsbn())
            .titre(livre.getTitre())
            .auteur(livre.getAuteur())
            .prix(livre.getPrix())
            .stock(livre.getStock())
            .disponible(livre.isDisponible())
            .build();
    }

    private Livre versDomaine(LivreJpaEntity entity) {
        return new Livre(
            entity.getIsbn(),
            entity.getTitre(),
            entity.getAuteur(),
            entity.getPrix(),
            entity.getStock()
        );
    }
}

7.6. Adaptateur primaire — REST Controller

// infrastructure/in/web/LivreController.java
package fr.formation.bibliotheque.infrastructure.in.web;

import fr.formation.bibliotheque.domain.exception.LivreIntrouvableException;
import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.in.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

/**
 * Adaptateur primaire REST — traduit les requêtes HTTP en appels use cases.
 * NE CONTIENT PAS de logique métier.
 * Gère uniquement la couche HTTP (codes de statut, sérialisation JSON).
 */
@RestController
@RequestMapping("/api/livres")
@RequiredArgsConstructor
public class LivreController {

    // Injection par les INTERFACES du domaine — pas par LivreService directement
    private final CreerLivreUseCase     creerLivreUseCase;
    private final ConsulterLivreUseCase consulterLivreUseCase;
    private final EmprunterLivreUseCase emprunterLivreUseCase;

    /** GET /api/livres — Liste tous les livres */
    @GetMapping
    public List<LivreReponse> listerTous() {
        return consulterLivreUseCase.trouverTous()
            .stream().map(LivreReponse::fromDomaine).toList();
    }

    /** GET /api/livres/disponibles — Livres disponibles */
    @GetMapping("/disponibles")
    public List<LivreReponse> listerDisponibles() {
        return consulterLivreUseCase.trouverDisponibles()
            .stream().map(LivreReponse::fromDomaine).toList();
    }

    /** GET /api/livres/{isbn} — Détail d'un livre */
    @GetMapping("/{isbn}")
    public ResponseEntity<LivreReponse> trouverParIsbn(@PathVariable String isbn) {
        return consulterLivreUseCase.trouverParIsbn(isbn)
            .map(l -> ResponseEntity.ok(LivreReponse.fromDomaine(l)))
            .orElse(ResponseEntity.notFound().build());
    }

    /** POST /api/livres — Créer un livre */
    @PostMapping
    public ResponseEntity<LivreReponse> creer(@RequestBody CreerLivreRequete requete) {
        Livre livre = creerLivreUseCase.creerLivre(
            new CreerLivreUseCase.CreerLivreCommande(
                requete.isbn(), requete.titre(), requete.auteur(),
                requete.prix(), requete.stockInitial()
            )
        );
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(LivreReponse.fromDomaine(livre));
    }

    /** POST /api/livres/{isbn}/emprunter — Emprunter un livre */
    @PostMapping("/{isbn}/emprunter")
    public ResponseEntity<LivreReponse> emprunter(
            @PathVariable String isbn,
            @RequestBody Map<String, String> body) {
        String membreId = body.getOrDefault("membreId", "ANONYME");
        Livre livre = emprunterLivreUseCase.emprunter(isbn, membreId);
        return ResponseEntity.ok(LivreReponse.fromDomaine(livre));
    }

    /** POST /api/livres/{isbn}/retourner — Retourner un livre */
    @PostMapping("/{isbn}/retourner")
    public ResponseEntity<LivreReponse> retourner(
            @PathVariable String isbn,
            @RequestBody Map<String, String> body) {
        String membreId = body.getOrDefault("membreId", "ANONYME");
        Livre livre = emprunterLivreUseCase.retourner(isbn, membreId);
        return ResponseEntity.ok(LivreReponse.fromDomaine(livre));
    }

    /** Gestion des erreurs métier → codes HTTP appropriés */
    @ExceptionHandler(LivreIntrouvableException.class)
    public ResponseEntity<Map<String, String>> gererLivreIntrouvable(
            LivreIntrouvableException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(Map.of("erreur", ex.getMessage(), "isbn", ex.getIsbn()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> gererArgInvalide(IllegalArgumentException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(Map.of("erreur", ex.getMessage()));
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<Map<String, String>> gererEtatInvalide(IllegalStateException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(Map.of("erreur", ex.getMessage()));
    }

    // ── DTOs HTTP (Records Java 17) ───────────────────────────────────────────

    /** DTO de requête — ce que le client envoie */
    record CreerLivreRequete(String isbn, String titre, String auteur,
                             BigDecimal prix, int stockInitial) {}

    /** DTO de réponse — ce que l'API retourne */
    record LivreReponse(String isbn, String titre, String auteur,
                        BigDecimal prix, int stock, boolean disponible) {
        static LivreReponse fromDomaine(Livre livre) {
            return new LivreReponse(livre.getIsbn(), livre.getTitre(),
                livre.getAuteur(), livre.getPrix(),
                livre.getStock(), livre.isDisponible());
        }
    }
}

7.7. Application principale Spring Boot

// BibliothequeApplication.java
package fr.formation.bibliotheque;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BibliothequeApplication {
    public static void main(String[] args) {
        SpringApplication.run(BibliothequeApplication.class, args);
    }
}
# application.properties
spring.datasource.url=jdbc:h2:mem:bibliothequedb;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.h2.console.enabled=true
server.port=8080

8. Tests dans l’architecture hexagonale

8.1. Tests unitaires du domaine — Sans aucune infrastructure

// test/domain/service/LivreServiceTest.java
package fr.formation.bibliotheque.domain.service;

import fr.formation.bibliotheque.domain.exception.LivreIntrouvableException;
import fr.formation.bibliotheque.domain.model.Livre;
import fr.formation.bibliotheque.domain.port.in.*;
import fr.formation.bibliotheque.domain.port.out.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

/**
 * Tests unitaires du service domaine.
 * ✅ Pas de Spring, pas de base de données, pas d'HTTP.
 * ✅ Mockito simule les ports de sortie.
 * ✅ Tests ultra-rapides (millisecondes).
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("Tests du LivreService")
class LivreServiceTest {

    // Mocks des ports de sortie
    @Mock private LivreRepository  livreRepository;
    @Mock private NotificationPort  notificationPort;
    @Mock private JournalPort       journalPort;

    // Le service à tester — avec les vrais ports de sortie mockés
    @InjectMocks
    private LivreService livreService;

    private static final String ISBN   = "9782070368228";
    private static final String MEMBRE = "M001";

    private Livre livreDisponible() {
        return new Livre(ISBN, "L'Étranger", "Albert Camus",
            new BigDecimal("8.50"), 3);
    }

    // ── Tests creerLivre ──────────────────────────────────────────────────────

    @Test
    @DisplayName("creerLivre — ISBN inexistant — livre créé et sauvegardé")
    void creerLivre_isbnNouveau_livreCreeEtSauvegarde() {
        when(livreRepository.existeParIsbn(ISBN)).thenReturn(false);
        when(livreRepository.sauvegarder(any())).thenAnswer(inv -> inv.getArgument(0));

        Livre result = livreService.creerLivre(
            new CreerLivreUseCase.CreerLivreCommande(
                ISBN, "L'Étranger", "Albert Camus", new BigDecimal("8.50"), 3));

        assertThat(result.getTitre()).isEqualTo("L'Étranger");
        assertThat(result.getStock()).isEqualTo(3);
        verify(livreRepository).sauvegarder(any(Livre.class));
    }

    @Test
    @DisplayName("creerLivre — ISBN existant — lève IllegalArgumentException")
    void creerLivre_isbnExistant_leveException() {
        when(livreRepository.existeParIsbn(ISBN)).thenReturn(true);

        assertThatThrownBy(() -> livreService.creerLivre(
            new CreerLivreUseCase.CreerLivreCommande(
                ISBN, "L'Étranger", "Albert Camus", new BigDecimal("8.50"), 3)))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining(ISBN);

        verify(livreRepository, never()).sauvegarder(any());
    }

    // ── Tests emprunter ───────────────────────────────────────────────────────

    @Test
    @DisplayName("emprunter — livre disponible — stock diminue, journal enregistré")
    void emprunter_livreDisponible_stockDiminueEtJournalEnregistre() {
        Livre livre = livreDisponible();
        when(livreRepository.trouverParIsbn(ISBN)).thenReturn(Optional.of(livre));
        when(livreRepository.sauvegarder(any())).thenAnswer(inv -> inv.getArgument(0));

        Livre resultat = livreService.emprunter(ISBN, MEMBRE);

        assertThat(resultat.getStock()).isEqualTo(2);
        verify(journalPort).enregistrerEmprunt(ISBN, MEMBRE);
        verify(livreRepository).sauvegarder(livre);
    }

    @Test
    @DisplayName("emprunter — ISBN inexistant — lève LivreIntrouvableException")
    void emprunter_isbnInexistant_leveException() {
        when(livreRepository.trouverParIsbn("ISBN_INCONNU")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> livreService.emprunter("ISBN_INCONNU", MEMBRE))
            .isInstanceOf(LivreIntrouvableException.class);
    }

    @Test
    @DisplayName("emprunter — stock zéro — lève IllegalStateException")
    void emprunter_stockZero_leveException() {
        Livre livreEpuise = new Livre(ISBN, "L'Étranger", "Albert Camus",
            new BigDecimal("8.50"), 0);
        when(livreRepository.trouverParIsbn(ISBN)).thenReturn(Optional.of(livreEpuise));

        assertThatThrownBy(() -> livreService.emprunter(ISBN, MEMBRE))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("disponible");
    }

    // ── Tests retourner ───────────────────────────────────────────────────────

    @Test
    @DisplayName("retourner — livre emprunté — stock augmente, notification si premier exemplaire")
    void retourner_livreBienRetourne_stockAugmenteEtNotificationSiPremier() {
        // Stock = 0 → après retour = 1 → notification "disponible"
        Livre livreEpuise = new Livre(ISBN, "L'Étranger", "Albert Camus",
            new BigDecimal("8.50"), 0);
        when(livreRepository.trouverParIsbn(ISBN)).thenReturn(Optional.of(livreEpuise));
        when(livreRepository.sauvegarder(any())).thenAnswer(inv -> inv.getArgument(0));

        Livre resultat = livreService.retourner(ISBN, MEMBRE);

        assertThat(resultat.getStock()).isEqualTo(1);
        verify(journalPort).enregistrerRetour(ISBN, MEMBRE);
        verify(notificationPort).notifierDisponibilite(eq(ISBN), any(), any());
    }

    // ── Tests consulter ───────────────────────────────────────────────────────

    @Test
    @DisplayName("trouverDisponibles — retourne seulement les livres avec stock > 0")
    void trouverDisponibles_retourneSeulementDisponibles() {
        when(livreRepository.trouverDisponibles())
            .thenReturn(List.of(livreDisponible()));

        List<Livre> disponibles = livreService.trouverDisponibles();

        assertThat(disponibles).hasSize(1);
        assertThat(disponibles.get(0).isDisponible()).isTrue();
    }
}

8.2. Tests du domaine pur — Sans Mockito

// test/domain/model/LivreTest.java
package fr.formation.bibliotheque.domain.model;

import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;

@DisplayName("Tests de l'entité Livre")
class LivreTest {

    private static final String ISBN = "9782070368228";

    private Livre livreAvecStock(int stock) {
        return new Livre(ISBN, "L'Étranger", "Albert Camus",
            new BigDecimal("8.50"), stock);
    }

    @Test
    @DisplayName("emprunter — stock disponible — stock décrémenté")
    void emprunter_stockDisponible_stockDecremente() {
        Livre livre = livreAvecStock(3);
        livre.emprunter();
        assertThat(livre.getStock()).isEqualTo(2);
        assertThat(livre.isDisponible()).isTrue();
    }

    @Test
    @DisplayName("emprunter — dernier exemplaire — livre indisponible après")
    void emprunter_dernierExemplaire_livreIndisponibleApres() {
        Livre livre = livreAvecStock(1);
        livre.emprunter();
        assertThat(livre.getStock()).isEqualTo(0);
        assertThat(livre.isDisponible()).isFalse();
    }

    @Test
    @DisplayName("emprunter — stock zéro — lève IllegalStateException")
    void emprunter_stockZero_leveException() {
        Livre livre = livreAvecStock(0);
        assertThatThrownBy(livre::emprunter)
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("disponible");
    }

    @Test
    @DisplayName("retourner — retour confirmé — stock incrémenté")
    void retourner_retourConfirme_stockIncremente() {
        Livre livre = livreAvecStock(0);
        livre.retourner();
        assertThat(livre.getStock()).isEqualTo(1);
        assertThat(livre.isDisponible()).isTrue();
    }

    @Test
    @DisplayName("appliquerRemise — 20% — prix réduit correctement")
    void appliquerRemise_20pourcent_prixReduit() {
        Livre livre = livreAvecStock(1);  // prix = 8.50
        livre.appliquerRemise(20);
        assertThat(livre.getPrix()).isEqualByComparingTo(new BigDecimal("6.80"));
    }

    @Test
    @DisplayName("appliquerRemise — 60% — lève IllegalArgumentException")
    void appliquerRemise_60pourcent_leveException() {
        Livre livre = livreAvecStock(1);
        assertThatThrownBy(() -> livre.appliquerRemise(60))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("50");
    }

    @Test
    @DisplayName("constructeur — ISBN invalide — lève IllegalArgumentException")
    void constructeur_isbnInvalide_leveException() {
        assertThatThrownBy(() ->
            new Livre("ISBN-TROP-COURT", "Titre", "Auteur", BigDecimal.TEN, 1))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    @DisplayName("constructeur — prix négatif — lève IllegalArgumentException")
    void constructeur_prixNegatif_leveException() {
        assertThatThrownBy(() ->
            new Livre(ISBN, "Titre", "Auteur", new BigDecimal("-1"), 1))
            .isInstanceOf(IllegalArgumentException.class);
    }
}

8.3. Tests d’intégration de l’adaptateur

// test/infrastructure/LivreJpaAdapterTest.java (version Spring Boot)
package fr.formation.bibliotheque.infrastructure.out.persistence;

import fr.formation.bibliotheque.domain.model.Livre;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;

@DataJpaTest
@Import(LivreJpaAdapter.class)
@DisplayName("Tests intégration LivreJpaAdapter")
class LivreJpaAdapterTest {

    @Autowired
    private LivreJpaAdapter livreJpaAdapter;

    private Livre livreTest() {
        return new Livre("9782070368228", "L'Étranger",
            "Albert Camus", new BigDecimal("8.50"), 3);
    }

    @Test
    @DisplayName("sauvegarder puis trouverParIsbn — round trip OK")
    void sauvegarder_puisTrouver_roundTripOk() {
        Livre livre = livreTest();
        livreJpaAdapter.sauvegarder(livre);

        Optional<Livre> trouve = livreJpaAdapter.trouverParIsbn("9782070368228");
        assertThat(trouve).isPresent();
        assertThat(trouve.get().getTitre()).isEqualTo("L'Étranger");
        assertThat(trouve.get().getStock()).isEqualTo(3);
    }

    @Test
    @DisplayName("trouverDisponibles — exclut les livres avec stock 0")
    void trouverDisponibles_excluStockZero() {
        livreJpaAdapter.sauvegarder(livreTest());
        livreJpaAdapter.sauvegarder(
            new Livre("9782070413119", "Le Petit Prince",
                "Saint-Exupéry", new BigDecimal("6.90"), 0));

        var disponibles = livreJpaAdapter.trouverDisponibles();
        assertThat(disponibles).hasSize(1);
        assertThat(disponibles.get(0).getTitre()).isEqualTo("L'Étranger");
    }
}

9. Bonnes pratiques et pièges à éviter

9.1. Ce qui va dans le domaine vs l’infrastructure

Dans le domaine ✅ Dans l’infrastructure ❌
Entités métier (Livre, Membre) Entités JPA (LivreJpaEntity)
Value Objects (Isbn, Prix) DTOs HTTP (LivreReponse)
Exceptions métier (LivreIntrouvable) Exceptions techniques (DataAccessException)
Ports (interfaces LivreRepository) Implémentations JPA, HTTP, SMTP
Services (use cases) Controllers, Schedulers, CLI
Règles métier Configuration Spring

9.2. Les erreurs les plus fréquentes

// ❌ ERREUR 1 — Annotation JPA dans une entité du domaine
@Entity                         // ← INTERDIT dans domain/model/ !
@Table(name = "livre")
public class Livre { ... }

// ✅ Solution : entité JPA séparée dans infrastructure/
public class LivreJpaEntity { ... }  // dans infrastructure/out/persistence/
public class Livre { ... }           // dans domain/model/ — Java pur

// ─────────────────────────────────────────────────────────────────────────────

// ❌ ERREUR 2 — Le service du domaine importe un adaptateur
import fr.formation.bibliotheque.infrastructure.out.persistence.LivreJpaAdapter;

class LivreService {
    private LivreJpaAdapter adapter; // ← dépend de l'implémentation !
}

// ✅ Solution : dépendre du PORT (interface)
class LivreService {
    private LivreRepository repo; // ← interface définie dans le domaine
}

// ─────────────────────────────────────────────────────────────────────────────

// ❌ ERREUR 3 — Logique métier dans le controller
@PostMapping("/emprunter/{isbn}")
public ResponseEntity<?> emprunter(@PathVariable String isbn) {
    Livre livre = livreRepo.findById(isbn).orElseThrow();
    if (livre.getStock() <= 0) {          // ← Règle métier dans le controller !
        return ResponseEntity.status(409).build();
    }
    livre.setStock(livre.getStock() - 1); // ← Manipulation directe
    livreRepo.save(livre);
    return ResponseEntity.ok(livre);
}

// ✅ Solution : la règle métier est dans le domaine
@PostMapping("/emprunter/{isbn}")
public ResponseEntity<LivreReponse> emprunter(@PathVariable String isbn, ...) {
    Livre livre = emprunterLivreUseCase.emprunter(isbn, membreId); // ← déléguer
    return ResponseEntity.ok(LivreReponse.fromDomaine(livre));
}

// ─────────────────────────────────────────────────────────────────────────────

// ❌ ERREUR 4 — Exposer les entités du domaine directement en JSON
@GetMapping("/{isbn}")
public Livre trouverParIsbn(@PathVariable String isbn) { // ← entité du domaine !
    return livreService.trouverParIsbn(isbn).orElseThrow();
}

// ✅ Solution : mapper vers un DTO de réponse
@GetMapping("/{isbn}")
public LivreReponse trouverParIsbn(@PathVariable String isbn) {
    return livreService.trouverParIsbn(isbn)
        .map(LivreReponse::fromDomaine) // ← DTO HTTP séparé
        .orElseThrow();
}

9.3. La règle des imports

Une classe du DOMAINE peut importer :
  ✅ D'autres classes du domaine
  ✅ Java standard (java.util, java.time, java.math...)
  ❌ JAMAIS : jakarta.persistence, org.springframework, ...

Une classe de l'INFRASTRUCTURE peut importer :
  ✅ Des classes du domaine (les ports, les entités, les exceptions)
  ✅ Des frameworks (Spring, JPA, Jackson...)
  ✅ Java standard

10. TP Final — GestionStock, un gestionnaire de stock

10.1. Présentation du projet

Vous allez construire GestionStock : une application de gestion de stock pour une petite boutique. Le projet existe en deux versions :

Le thème est volontairement simple pour vous concentrer sur l’architecture, pas sur la complexité métier.

10.2. Modèle du domaine

PRODUIT                         CATEGORIE (enum)
────────────────────────        ─────────────────
id (UUID)                       ELECTRONIQUE
nom                             VETEMENT
description                     ALIMENTATION
prix (BigDecimal)               LIBRAIRIE
quantiteEnStock                 AUTRE
quantiteMinimale (seuil alerte)
categorie
actif

MOUVEMENT_STOCK                 TYPE_MOUVEMENT (enum)
────────────────────────        ─────────────────────
id                              ENTREE
produitId                       SORTIE
typeMouvement                   AJUSTEMENT
quantite
dateHeure
motif

10.3. Use Cases à implémenter

Ports d'entrée (use cases) :

CreerProduitUseCase
  → creerProduit(CreerProduitCommande) : Produit

ConsulterStockUseCase
  → trouverParId(String id) : Optional<Produit>
  → trouverTous() : List<Produit>
  → trouverEnAlerte() : List<Produit>  ← stock < quantiteMinimale
  → rechercherParNom(String nom) : List<Produit>

MouvementStockUseCase
  → entreeStock(String produitId, int quantite, String motif) : Produit
  → sortieStock(String produitId, int quantite, String motif) : Produit
  → obtenirHistorique(String produitId) : List<MouvementStock>

AlerteStockUseCase
  → verifierAlertes() : List<Produit>  ← produits sous le seuil
  → definirSeuilMinimal(String produitId, int seuil) : Produit

10.4. Structure attendue

gestion-stock/                   ← Version A : sans Spring Boot
├── pom.xml
└── src/main/java/fr/formation/stock/
    ├── Main.java
    ├── domain/
    │   ├── model/
    │   │   ├── Produit.java
    │   │   ├── MouvementStock.java
    │   │   └── Categorie.java       (enum)
    │   ├── exception/
    │   │   ├── ProduitIntrouvableException.java
    │   │   └── StockInsuffisantException.java
    │   ├── port/
    │   │   ├── in/
    │   │   │   ├── CreerProduitUseCase.java
    │   │   │   ├── ConsulterStockUseCase.java
    │   │   │   ├── MouvementStockUseCase.java
    │   │   │   └── AlerteStockUseCase.java
    │   │   └── out/
    │   │       ├── ProduitRepository.java
    │   │       └── MouvementRepository.java
    │   └── service/
    │       └── StockService.java       ← implémente tous les use cases
    └── infrastructure/
        ├── config/
        │   └── ApplicationConfig.java
        ├── in/cli/
        │   └── StockCliAdapter.java
        └── out/persistence/
            └── InMemoryProduitRepository.java

gestion-stock-spring/            ← Version B : avec Spring Boot
├── pom.xml
└── src/main/java/fr/formation/stock/
    ├── StockApplication.java
    ├── domain/                  ← IDENTIQUE à la version A
    └── infrastructure/
        ├── config/
        │   └── BeanConfiguration.java
        ├── in/web/
        │   └── ProduitController.java
        └── out/persistence/
            ├── ProduitJpaEntity.java
            ├── SpringDataProduitRepository.java
            └── ProduitJpaAdapter.java

10.5. L’entité Produit du domaine

// domain/model/Produit.java
package fr.formation.stock.domain.model;

import java.math.BigDecimal;
import java.util.Objects;
import java.util.UUID;

/**
 * Entité du domaine — représente un produit en stock.
 * AUCUNE annotation technique.
 */
public class Produit {

    private final String     id;    // UUID généré à la création
    private String           nom;
    private String           description;
    private BigDecimal       prix;
    private int              quantiteEnStock;
    private int              quantiteMinimale;  // Seuil d'alerte
    private Categorie        categorie;
    private boolean          actif;

    public Produit(String nom, String description, BigDecimal prix,
                   int quantiteInitiale, int quantiteMinimale, Categorie categorie) {
        valider(nom, prix, quantiteInitiale, quantiteMinimale);
        this.id               = UUID.randomUUID().toString();
        this.nom              = nom;
        this.description      = description;
        this.prix             = prix;
        this.quantiteEnStock  = quantiteInitiale;
        this.quantiteMinimale = quantiteMinimale;
        this.categorie        = categorie;
        this.actif            = true;
    }

    // Constructeur pour reconstruction depuis la persistance
    public Produit(String id, String nom, String description, BigDecimal prix,
                   int quantiteEnStock, int quantiteMinimale,
                   Categorie categorie, boolean actif) {
        this.id               = id;
        this.nom              = nom;
        this.description      = description;
        this.prix             = prix;
        this.quantiteEnStock  = quantiteEnStock;
        this.quantiteMinimale = quantiteMinimale;
        this.categorie        = categorie;
        this.actif            = actif;
    }

    // ── Règles métier ────────────────────────────────────────────────────────

    public Produit ajouterStock(int quantite) {
        if (quantite <= 0)
            throw new IllegalArgumentException("La quantité ajoutée doit être positive.");
        this.quantiteEnStock += quantite;
        return this;
    }

    public Produit retirerStock(int quantite) {
        if (quantite <= 0)
            throw new IllegalArgumentException("La quantité retirée doit être positive.");
        if (quantite > this.quantiteEnStock)
            throw new fr.formation.stock.domain.exception.StockInsuffisantException(
                this.nom, this.quantiteEnStock, quantite);
        this.quantiteEnStock -= quantite;
        return this;
    }

    public boolean estEnAlerte() {
        return quantiteEnStock <= quantiteMinimale;
    }

    public boolean estDisponible() {
        return actif && quantiteEnStock > 0;
    }

    // ── Validation ───────────────────────────────────────────────────────────
    private static void valider(String nom, BigDecimal prix, int qte, int qteMin) {
        if (nom == null || nom.isBlank())
            throw new IllegalArgumentException("Le nom du produit est obligatoire.");
        if (prix == null || prix.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Le prix ne peut pas être négatif.");
        if (qte < 0)
            throw new IllegalArgumentException("La quantité initiale ne peut pas être négative.");
        if (qteMin < 0)
            throw new IllegalArgumentException("Le seuil minimal ne peut pas être négatif.");
    }

    // ── Getters ──────────────────────────────────────────────────────────────
    public String     getId()                { return id; }
    public String     getNom()               { return nom; }
    public void       setNom(String n)       { this.nom = n; }
    public String     getDescription()       { return description; }
    public void       setDescription(String d){ this.description = d; }
    public BigDecimal getPrix()              { return prix; }
    public void       setPrix(BigDecimal p)  { this.prix = p; }
    public int        getQuantiteEnStock()   { return quantiteEnStock; }
    public int        getQuantiteMinimale()  { return quantiteMinimale; }
    public void       setQuantiteMinimale(int q) { this.quantiteMinimale = q; }
    public Categorie  getCategorie()         { return categorie; }
    public boolean    isActif()              { return actif; }
    public void       setActif(boolean a)    { this.actif = a; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Produit)) return false;
        return Objects.equals(id, ((Produit) o).id);
    }

    @Override
    public int hashCode() { return Objects.hash(id); }

    @Override
    public String toString() {
        return String.format("Produit{id='%s', nom='%s', stock=%d, alerte=%s}",
            id.substring(0, 8), nom, quantiteEnStock, estEnAlerte() ? "⚠️" : "OK");
    }
}

10.6. Missions du TP Final

Mission 1 — Domaine

  1. Créez le projet Maven gestion-stock dans Eclipse.
  2. Implémentez les entités Produit et MouvementStock.
  3. Créez l’enum Categorie et TypeMouvement.
  4. Créez les exceptions ProduitIntrouvableException et StockInsuffisantException.
  5. Créez les 4 interfaces de ports d’entrée et les 2 ports de sortie.
  6. Implémentez StockService qui réalise tous les use cases.
  7. Tests : écrivez au moins 10 tests unitaires pour StockService (avec Mockito) et Produit.

Mission 2 — Version sans Spring Boot

  1. Implémentez InMemoryProduitRepository (Map en mémoire).
  2. Implémentez InMemoryMouvementRepository.
  3. Implémentez StockCliAdapter : menu interactif avec les opérations suivantes :
    • Créer un produit
    • Lister tous les produits (avec indicateur d’alerte ⚠️)
    • Entrée de stock (réapprovisionnement)
    • Sortie de stock (vente)
    • Voir les produits en alerte
    • Voir l’historique d’un produit
  4. Créez ApplicationConfig pour assembler les dépendances.
  5. Testez : créer 3 produits, faire des mouvements, vérifier les alertes.

Mission 3 — Version Spring Boot (25 pts)

  1. Créez le projet gestion-stock-spring depuis Spring Initializr.
  2. Copiez le domaine sans modification.
  3. Créez ProduitJpaEntity et ProduitJpaAdapter.
  4. Créez BeanConfiguration pour câbler le service.
  5. Créez ProduitController avec les endpoints :
    • GET /api/produits — liste tous les produits
    • GET /api/produits/{id} — détail d’un produit
    • GET /api/produits/alertes — produits en alerte
    • POST /api/produits — créer un produit
    • POST /api/produits/{id}/entree — entrée de stock
    • POST /api/produits/{id}/sortie — sortie de stock
  6. Testez avec curl ou un outil HTTP (Postman, HTTPie…).

Mission 4 — Tests

  1. Tests unitaires du domaine : ProduitTest (8 tests minimum) et StockServiceTest (10 tests minimum).
  2. Tests d’intégration : ProduitJpaAdapterTest avec @DataJpaTest.
  3. Tests du controller : ProduitControllerTest avec @WebMvcTest.

Annexe — Aide-mémoire et ressources

Règles de l’architecture hexagonale en 5 points

1. Le domaine ne dépend de rien — Java pur, zéro import externe
2. Les ports sont des interfaces Java dans le domaine
3. Les adaptateurs implémentent les ports — jamais l'inverse
4. La dépendance va toujours vers l'intérieur (vers le domaine)
5. Un adaptateur peut être remplacé sans toucher au domaine

Checklist avant de rendre le projet

Domaine
☐ Aucune annotation Spring (@Service, @Component...) dans domain/
☐ Aucun import JPA (jakarta.persistence) dans domain/
☐ Aucun import HTTP dans domain/
☐ Les entités ont des règles métier (pas que des getters/setters)
☐ Les exceptions sont des exceptions métier (pas techniques)

Ports
☐ Les ports d'entrée sont dans domain/port/in/
☐ Les ports de sortie sont dans domain/port/out/
☐ Chaque port = une seule responsabilité (1 use case par interface)

Adaptateurs
☐ Les adaptateurs implémentent les ports (implements PortInterface)
☐ Pas de logique métier dans les adaptateurs
☐ Mapping Domaine ↔ JPA dans l'adaptateur JPA (pas dans l'entité JPA)

Tests
☐ Tests unitaires du domaine sans Spring, sans base de données
☐ Mockito pour les ports de sortie dans les tests de service
☐ Tests d'intégration pour les adaptateurs JPA avec @DataJpaTest

Ressources

Ressource URL
Article original d’Alistair Cockburn https://alistair.cockburn.us/hexagonal-architecture
Architecture hexagonale en Java (blog) https://reflectoring.io/spring-hexagonal
Ports and Adapters — Netflix https://netflixtechblog.com
Get Your Hands Dirty on Clean Architecture https://leanpub.com/get-your-hands-dirty-on-clean-architecture
Spring Initializr https://start.spring.io

Auteur : Philippe Bouget — Architecture Hexagonale · Java 17 · Spring Boot 3.2

— Fin du cours —