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

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.