Skip to Content
BackendArchitecture Hexagonale

Architecture Hexagonale avec Spring Boot

Page dédiée qui complète la section Pattern hexagonal de /backend/spring-boot. Elle approfondit le flux de requête, la stratégie de test par couche, les conventions de nommage Spring, et l’intégration avec Spring Data / JPA.

Le terme architecture hexagonale (ou Ports & Adapters) vient d’Alistair Cockburn (2005). En Java/Spring, il se traduit par une séparation claire : domain (métier pur) → application (use-cases) → infrastructure (détails techniques) avec les web comme couche d’entrée.

Diagramme de flux — Requête HTTP

Client HTTP ┌─────────────────────────────────────────┐ │ COUCHE WEB (Entrypoint) │ │ ┌─────────────┐ ┌───────────────┐ │ │ │ Controller │───▶│ DTO / Mapper │ │ │ └─────────────┘ └───────────────┘ │ ├─────────────────────────────────────────┤ │ COUCHE APPLICATION (Use-cases) │ │ ┌──────────────────────────────────┐ │ │ │ GestionProduitService │ │ │ │ • Validation métier │ │ │ │ • Orchestration des flux │ │ │ └──────────────────────────────────┘ │ ├─────────────────────────────────────────┤ │ COUCHE DOMAIN (Métier pur) │ │ ┌─────────┐ ┌──────────────────────┐ │ │ │ Produit │ │ ProduitRepository │ │ │ │ (record)│ │ (interface / Port) │ │ │ └─────────┘ └──────────────────────┘ │ ├─────────────────────────────────────────┤ │ COUCHE INFRASTRUCTURE (Adapters) │ │ ┌──────────────┐ ┌───────────────┐ │ │ │ ProduitJpa │ │ ProduitMapper │ │ │ │ Repository │ │ (Entité ↔ Dom.)│ │ │ └──────┬───────┘ └───────────────┘ │ │ ▼ │ │ PostgreSQL / H2 │ └─────────────────────────────────────────┘

Stratégie de test par couche

CoucheType de testOutilsCe qu’on vérifie
domain/UnitéJUnit 5, AssertJRègles métier pures, invariants
application/Unité (mockée)JUnit 5, Mockito / AssertJLogique d’orchestration, validation
infrastructure/Intégration@DataJpaTest / @SpringBootTestMapper JPA↔Domain, interactions BD
web/Intégration@SpringBootTest + TestRestTemplateMapping HTTP, codes de statut, sérialisation

Exemple : test du use-case (application)

// application/GestionProduitServiceTest.java class GestionProduitServiceTest { @Mock ProduitRepository produitRepository; @InjectMocks GestionProduitService service; @Test void creerProduit_AvecPrixNegatif_LanceException() { var produit = new Produit(UUID.randomUUID(), "Dari", "oko", BigDecimal.valueOf(-1)); assertThrows(IllegalArgumentException.class, () -> service.creer(produit)); verify(produitRepository, never()).save(any()); } @Test void getProduit_Trouvé_RetourneProduit() { var expected = new Produit(UUID.randomUUID(), "Dari", "oko", new BigDecimal("10.50")); when(produitRepository.findById(expected.id())).thenReturn(Optional.of(expected)); var result = service.get(expected.id()); assertEquals(expected, result); } }

Exemple : test du mapper JPA↔Domain

// infrastructure/EntityMapperTest.java class EntityMapperTest { @Test void toDomain_RécupèreTousLesChamps() { var entity = new JpaProduitEntity(); entity.setId(UUID.randomUUID()); entity.setNom("Dari"); entity.setDescription("oko"); entity.setPrix(new BigDecimal("10.50")); var domaine = EntityMapper.toDomain(entity); assertEquals(entity.getNom(), domaine.nom()); assertEquals(entity.getPrix(), domaine.prix()); } }

Conventions Spring pour l’hexagonal

Spring ne fournit pas d’annotations officielles pour chaque couche hexagonale, mais des conventions émergent dans l’écosystème :

ConventionUtilisationImplémentation typique
@ApplicationServiceService qui orchestre un use-case@Service sur la classe application/
@AdapterImplémentation d’un port@Repository, @Component dans infrastructure/
@DomainEventÉvénement émis par le domaineclasse POJO dans domain/event/

Exemples concrets

// application/GestionProduitService.java @Service // → convention @ApplicationService public class GestionProduitService { private final ProduitRepository repo; public GestionProduitService(ProduitRepository repo) { this.repo = repo; } // ... }
// infrastructure/ProduitJpaRepository.java @Repository // → convention @Adapter public class ProduitJpaRepository implements ProduitRepository { // ... }

Note : si vous utilisez un framework comme Hexagonal Spring Boot  ou Hexagonal Architecture Framework (HAF) , les annotations @ApplicationService, @Adapter, @AdapterPort sont des réelles annotations Java. Sans framework, @Service et @Repository suffisent à condition de bien structurer les paquets.

Cohabitation avec Spring Data / JPA

Spring Data est la forme la plus courante d’adaptateur infrastructurel. Le secret est de ne jamais importer org.springframework.data dans le domaine.

Architecture de l’adaptateur JPA

domain/ProduitRepository.java ← Interface (port) — AUCUNE dépendance Spring infrastructure/ProduitJpaRepository.java ← @Repository — implémente l'interface infrastructure/JpaProduitEntity.java ← @Entity JPA (dépend de spring-data) infrastructure/EntityMapper.java ← Convertit Entity ↔ Domain

pom.xml — Ce qui va dans infrastructure/

<!-- INFRASTRUCTURE --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- DOMAIN — AUCUN starter JPA ici --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <!-- Seulement ce qui est utile au domaine (logging, validation Bean Validation) --> </dependency>

Pourquoi cette séparation ?

  1. Testabilité : le domaine est un POJO pur — testable sans Spring, sans BD.
  2. Découplage : on peut remplacer JPA par du JDBC ou du SQL pur sans toucher au métier.
  3. Clarté : en lisant domain/, on voit les règles métier sans bruit framework.

Exemple de repository JPA adapté

// --- Domain (port) --- public interface ProduitRepository { Optional<Produit> findById(UUID id); List<Produit> findAll(); Produit save(Produit produit); void deleteById(UUID id); } // --- Infrastructure (adapter) --- @Repository public class ProduitJpaRepository implements ProduitRepository { private final JpaProduitRepository jpaRepository; public ProduitJpaRepository(JpaProduitRepository jpaRepository) { this.jpaRepository = jpaRepository; } @Override public Optional<Produit> findById(UUID id) { return jpaRepository.findById(id).map(EntityMapper::toDomain); } @Override public Produit save(Produit produit) { var entity = EntityMapper.toEntity(produit); return EntityMapper.toDomain(jpaRepository.save(entity)); } @Override public void deleteById(UUID id) { jpaRepository.deleteById(id); } } // JPA Repository natif (ne JAMAIS être injecté en dehors de infrastructure/) public interface JpaProduitRepository extends JpaRepository<JpaProduitEntity, UUID> {} // Entity JPA (dépend de spring-data) @Entity @Table(name = "produits") public class JpaProduitEntity { @Id private UUID id; private String nom; private String description; private BigDecimal prix; // Getters / Setters }

Pièges courants (architecture hexagonale)

1. Fuite du domaine vers l’infrastructure

Le domaine ne doit jamais importer de classes Spring. Si une dépendance fuite :

// ❌ MAUVAIS — Le domaine dépend de Spring package com.monapp.domain; import org.springframework.data.annotation.Id; // ← Fuite ! public class Produit { @Id // ← Ne jamais utiliser ici private UUID id; // ... }

2. Injection directe du JPA Repository dans le controller

// ❌ MAUVAIS — Controller accède directement à JPA @RestController public class ProduitController { private final JpaProduitRepository jpaRepository; // ← Dépendance Spring dans web/ // ... } // ✅ BON — Le controller injecte le Service application @RestController public class ProduitController { private final GestionProduitService service; // ← Couche application // ... }

3. Test qui repose sur Spring pour tester le domaine

// ❌ MAUVAIS — Test d'un POJO domaine avec Spring @SpringBootTest class ProduitTest { @Test void leDomaineDoitEtrePur() { ... } // ← Pas besoin de Spring pour tester un POJO } // ✅ BON — Test unitaire simple class ProduitTest { @Test void leDomaineDoitEtrePur() { var produit = new Produit(UUID.randomUUID(), "Dari", "oko", BigDecimal.TEN); assertNotNull(produit.prix()); } }

4. Adapter qui retourne l’Entity JPA au lieu du Domain

// ❌ MAUVAIS — Le Service reçoit un Entity JPA public interface ProduitRepository { Optional<JpaProduitEntity> findById(UUID id); // ← Fuit la couche JPA } // ✅ BON — Le port échange uniquement avec des Domain Objects public interface ProduitRepository { Optional<Produit> findById(UUID id); // ← JPA est opaque }

Voir aussi