Architecture hexagonale et comment l’implémenter avec Spring Boot
L’architecture hexagonale, aussi appelée ports & adapters (Alistair Cockburn, 2005), repose sur une idée simple : le cœur métier ne doit dépendre de rien d’extérieur. Ni du framework web, ni de la base de données, ni du broker de messages. Tout ça, ce sont des détails qu’on vient brancher autour du domaine, et pas l’inverse.
Il n’y a qu’une seule règle de dépendance : tout pointe vers le domaine, le domaine ne pointe vers rien. Sur la JVM avec Spring Boot, la difficulté concrète est de garder les annotations et les types Spring en dehors du cœur.
Le vocabulaire en trois mots
- Domaine : les entités, la logique métier, les règles. Du Java ou du Kotlin pur, sans dépendance technique.
- Port : une interface définie par le domaine pour exprimer un besoin. Un port driving (entrant) est appelé depuis l’extérieur, par exemple « ouvrir un compte ». Un port driven (sortant) exprime ce dont le domaine a besoin, par exemple « persister un compte ».
- Adapter : une implémentation concrète d’un port. Le contrôleur REST est un adapter entrant, le repository JPA un adapter sortant.
Un découpage de packages qui tient dans le temps
Le plus important, c’est de rendre la frontière visible dès l’arborescence :
com.example.account
├── domain
│ ├── model // Account, AccountId, Money… (POJO/POKO purs)
│ └── service // logique métier
├── application
│ ├── port.in // CreateAccountUseCase (driving)
│ └── port.out // LoadAccountPort, SaveAccountPort (driven)
└── adapter
├── in.web // AccountController (Spring MVC)
└── out.persistence // AccountJpaAdapter, AccountEntity
Seuls les packages adapter connaissent Spring, JPA ou Jackson. domain et application n’importent que le JDK et leurs propres types.
Les ports : de simples interfaces
Le port entrant exprime un cas d’usage, le port sortant un besoin du domaine.
// application/port/in
public interface CreateAccountUseCase {
AccountId create(CreateAccountCommand command);
}
// application/port/out
public interface SaveAccountPort {
void save(Account account);
}
public interface LoadAccountPort {
Optional<Account> loadById(AccountId id);
}
Aucune annotation Spring ici, ce sont des contrats du domaine.
Le service applicatif : là où vit l’orchestration
Le service implémente le port entrant et s’appuie sur les ports sortants. C’est le seul endroit côté application où on tolère l’annotation Spring @Service, parce que l’objet doit être géré par le conteneur. Il ne dépend malgré tout que d’interfaces :
@Service
class CreateAccountService implements CreateAccountUseCase {
private final SaveAccountPort saveAccount;
private final LoadAccountPort loadAccount;
CreateAccountService(SaveAccountPort saveAccount, LoadAccountPort loadAccount) {
this.saveAccount = saveAccount;
this.loadAccount = loadAccount;
}
@Override
public AccountId create(CreateAccountCommand command) {
Account account = Account.open(command.owner(), command.initialBalance());
saveAccount.save(account);
return account.id();
}
}
Si vous voulez sortir même le @Service du code applicatif, déclarez le bean dans une classe @Configuration au niveau de l’adapter. C’est plus puriste. En pratique, une annotation aussi neutre que @Service reste un compromis acceptable.
L’adapter entrant : le contrôleur REST
Le contrôleur ne contient aucune logique métier. Il traduit du HTTP en commande, appelle le port, puis traduit le résultat en réponse :
@RestController
@RequestMapping("/accounts")
class AccountController {
private final CreateAccountUseCase createAccount;
AccountController(CreateAccountUseCase createAccount) {
this.createAccount = createAccount;
}
@PostMapping
ResponseEntity<AccountResponse> create(@RequestBody @Valid CreateAccountRequest body) {
AccountId id = createAccount.create(body.toCommand());
return ResponseEntity.created(URI.create("/accounts/" + id.value()))
.body(new AccountResponse(id.value()));
}
}
CreateAccountRequest et AccountResponse sont des DTO propres à l’adapter web. Ils ne fuient jamais dans le domaine, et c’est précisément ce qui vous permet de faire évoluer l’API REST sans toucher au métier.
L’adapter sortant : la persistance
Côté persistance, on sépare l’entité JPA, qui n’est qu’un détail de mapping, de l’entité de domaine, et on implémente les ports sortants :
@Component
class AccountPersistenceAdapter implements LoadAccountPort, SaveAccountPort {
private final AccountRepository repository; // Spring Data JPA
private final AccountMapper mapper;
AccountPersistenceAdapter(AccountRepository repository, AccountMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}
@Override
public Optional<Account> loadById(AccountId id) {
return repository.findById(id.value()).map(mapper::toDomain);
}
@Override
public void save(Account account) {
repository.save(mapper.toEntity(account));
}
}
AccountEntity (avec ses @Entity, @Id et compagnie) reste dans le package persistance. Le domaine ignore jusqu’à l’existence de JPA. Le jour où vous changez de store, vous réécrivez cet adapter, et rien d’autre.
Faire respecter la frontière
La théorie ne tient que si la règle de dépendance est vérifiée automatiquement. Un test ArchUnit suffit à empêcher le domaine de réimporter Spring par mégarde :
@Test
void le_domaine_ne_depend_pas_de_spring() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta.persistence..")
.check(new ClassFileImporter().importPackages("com.example"));
}
Sans ce garde-fou, la séparation se dégrade en quelques sprints. Il y a toujours quelqu’un pour glisser une annotation « juste pour aller plus vite ».
Ce que ça apporte, et ce que ça coûte
Le vrai gain : le métier se teste sans démarrer Spring (des tests unitaires rapides sur le domaine et les services, avec de simples fakes à la place des ports), et les choix techniques deviennent remplaçables. Le coût : plus de classes, du mapping DTO vers domaine vers entité, et un peu de cérémonie.
Le compromis est rentable sur un domaine riche et destiné à durer. Il l’est beaucoup moins sur un CRUD anémique, où la couche de mapping ne fait que recopier des champs d’un objet à l’autre. Comme souvent, la bonne dose d’hexagonal dépend de la complexité métier que vous avez réellement à protéger.