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 leswebcomme 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
| Couche | Type de test | Outils | Ce qu’on vérifie |
|---|---|---|---|
domain/ | Unité | JUnit 5, AssertJ | Règles métier pures, invariants |
application/ | Unité (mockée) | JUnit 5, Mockito / AssertJ | Logique d’orchestration, validation |
infrastructure/ | Intégration | @DataJpaTest / @SpringBootTest | Mapper JPA↔Domain, interactions BD |
web/ | Intégration | @SpringBootTest + TestRestTemplate | Mapping 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 :
| Convention | Utilisation | Implémentation typique |
|---|---|---|
@ApplicationService | Service qui orchestre un use-case | @Service sur la classe application/ |
@Adapter | Implémentation d’un port | @Repository, @Component dans infrastructure/ |
@DomainEvent | Événement émis par le domaine | classe 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,@AdapterPortsont des réelles annotations Java. Sans framework,@Serviceet@Repositorysuffisent à 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 ↔ Domainpom.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 ?
- Testabilité : le domaine est un POJO pur — testable sans Spring, sans BD.
- Découplage : on peut remplacer JPA par du JDBC ou du SQL pur sans toucher au métier.
- 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
- /backend/spring-boot — chapitre Pattern hexagonal (vue d’ensemble + POM)
- /backend/api-patterns — conventions REST applicables aux entrypoints
- /backend/api-auth — sécuriser les contrôleurs web