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.
ProduitController
ProduitService
ProduitRepository
Architecture spaghetti — tout dépend de tout Controller ──────────────────────────────▶ Base de données │ ▲ └──────────▶ Service ──────────────────────┘ │ └──────────▶ API externe HTTP │ └──────────▶ Envoi d'email │ └──────────▶ Fichier CSV
Les conséquences :
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.
💡 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.
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] │ └─────────────────────────────────────────────────────────────┘
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.
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 }
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
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); } }
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; } }
// 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); } }
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); }
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); }
// 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()); } }
// 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(); } }
// 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); } }
// 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; } }
Sans Spring Boot, on assemble manuellement les dépendances dans une classe de configuration ou directement dans le main().
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)); } }
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
Spring Boot n’entre que dans la couche infrastructure. Le domaine reste identique — pas une seule annotation Spring dans domain/.
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)
<?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>
Le domaine (Livre, LivreService, LivreRepository…) est exactement le même qu’en version sans Spring Boot. C’est la force de l’architecture hexagonale.
Livre
LivreService
LivreRepository
💡 Vous pouvez copier-coller le package domain/ d’un projet à l’autre sans modification. C’est la définition de la portabilité.
// 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); } }
// 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() ); } }
// 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()); } } }
// 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
// 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(); } }
// 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); } }
// 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"); } }
Membre
LivreJpaEntity
Isbn
Prix
LivreReponse
LivreIntrouvable
DataAccessException
// ❌ 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(); }
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
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.
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
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
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
// 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"); } }
Mission 1 — Domaine
gestion-stock
Produit
MouvementStock
Categorie
TypeMouvement
ProduitIntrouvableException
StockInsuffisantException
StockService
Mission 2 — Version sans Spring Boot
InMemoryProduitRepository
InMemoryMouvementRepository
StockCliAdapter
ApplicationConfig
Mission 3 — Version Spring Boot (25 pts)
gestion-stock-spring
ProduitJpaEntity
ProduitJpaAdapter
BeanConfiguration
GET /api/produits
GET /api/produits/{id}
GET /api/produits/alertes
POST /api/produits
POST /api/produits/{id}/entree
POST /api/produits/{id}/sortie
Mission 4 — Tests
ProduitTest
StockServiceTest
ProduitJpaAdapterTest
@DataJpaTest
ProduitControllerTest
@WebMvcTest
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
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
Auteur : Philippe Bouget — Architecture Hexagonale · Java 17 · Spring Boot 3.2
— Fin du cours —