Java · Architecture logicielle

Clean Architecture en Java : placer les règles métier face à Spring

Comprendre la direction des dépendances, le rôle des cas d’usage et les compromis de Spring/JPA sans multiplier les couches.

Quand une règle métier ne peut être vérifiée qu’en démarrant Spring, en ouvrant une connexion et en envoyant une requête HTTP, le test traverse toute l’application. Clean Architecture propose de déplacer les dépendances pour tester les règles de l’application sans charger ces détails techniques.

Commencer par les signatures d’un cas d’usage

Ce parcours s’adresse aux développeurs qui lisent des interfaces Java et connaissent le rôle de Spring et de JPA. Choisissez un cas d’usage de votre application, par exemple placer une commande. Ouvrez son interface, son implémentation et les types de ses paramètres et résultats; relevez les imports Spring, les DTO HTTP et les entités JPA qui y apparaissent.

Pour chaque dépendance trouvée, demandez quelle règle oblige le scénario à la connaître. Un ResponseEntity utilisé comme résultat suggère de déplacer la traduction HTTP dans le contrôleur; une entité JPA exposée par le repository invite à examiner le mapping de persistance. Si les signatures restent exprimées dans les types du scénario, poursuivez l’inspection des dépendances de l’implémentation. Ce premier contrôle aide à choisir une frontière; il ne prouve pas l’indépendance de tout le code. Les exemples ci-dessous sont des fragments, avec types auxiliaires et configuration omis.

La règle importante : les dépendances vont vers les règles de l’application

Dans son article de 2012, Robert C. Martin rassemble autour de la séparation des préoccupations plusieurs familles d’architectures dont l’architecture hexagonale. Son principe central est la règle de dépendance : le code source pointe vers l’intérieur, des mécanismes vers les règles de l’application. Le code intérieur ne nomme donc ni les classes d’un framework, ni les formats d’une API, ni les entités d’un ORM. Martin précise que les cercles de son schéma n’imposent aucun nombre de couches (texte de Martin).

Le sens des appels à l’exécution peut différer de celui des dépendances de code. Le contrôleur appelle le cas d’usage, qui appelle un contrat de persistance défini près de lui. L’adaptateur JPA implémente ce contrat. L’appel traverse la frontière vers la base; la dépendance de compilation pointe vers l’abstraction intérieure. Les types de cette frontière doivent rester indépendants de Spring MVC et de JPA.

Suivre une commande de la requête au stockage

Prenons une application fictive qui accepte une commande avec une ou plusieurs lignes. L’adaptateur HTTP traduit la requête en entrée du cas d’usage; celui-ci construit le modèle, le fait passer à l’état placé, demande sa sauvegarde et retourne son identifiant. Le détail des objets métier et de leurs invariants est développé dans DDD en Java : faire coïncider le modèle et le langage métier.

Les extraits illustrent ces frontières. Les imports et types auxiliaires sont omis.

public record OrderLineInput(String sku, int quantity) {}
public record PlaceOrderCommand(List<OrderLineInput> lines) {}
public record PlaceOrderResult(OrderId orderId) {}

public interface PlaceOrder {
    PlaceOrderResult handle(PlaceOrderCommand command);
}

public interface OrderRepository {
    void save(Order order);
}

PlaceOrder est le port entrant : il exprime l’action offerte par l’application. OrderRepository décrit la sauvegarde requise par le scénario sans imposer la technologie de stockage. Les entrées et le résultat gardent leurs propres types au lieu d’exposer des objets HTTP ou JPA.

L’implémentation du cas d’usage orchestre la séquence. Elle peut déléguer les règles d’état à l’objet métier plutôt que les répartir dans un service procédural :

final class PlaceOrderService implements PlaceOrder {
    private final OrderRepository orders;
    private final Supplier<OrderId> ids;

    PlaceOrderService(OrderRepository orders, Supplier<OrderId> ids) {
        this.orders = orders;
        this.ids = ids;
    }

    @Override
    public PlaceOrderResult handle(PlaceOrderCommand command) {
        List<OrderLine> lines = command.lines().stream()
            .map(line -> new OrderLine(line.sku(), line.quantity()))
            .toList();
        Order order = Order.create(ids.get(), lines);
        order.place();
        orders.save(order);
        return new PlaceOrderResult(order.id());
    }
}

Le service reçoit le générateur d’identifiant en dépendance. Un adaptateur Spring Boot convertit le JSON en PlaceOrderCommand; l’adaptateur de persistance traduit l’agrégat en représentation JPA et appelle Spring Data. La configuration relie ces composants au démarrage. Les règles de construction et de transition restent dans Order, tandis que PlaceOrderService orchestre le scénario.

Cette organisation rend explicite le rôle de chaque frontière :

Zone Responsabilité dans l’exemple Dépendances à éviter vers l’intérieur
Entrée HTTP Décoder la requête, appeler le cas d’usage, choisir le statut HTTP Laisser le contrôleur décider du cycle de vie métier
Application Orchestrer PlaceOrder et ses dépendances Recevoir les DTO HTTP ou les lignes JPA
Domaine Exprimer la commande et ses invariants Dépendre de Spring ou du schéma de stockage
Sortie JPA Traduire le modèle et exécuter les accès Faire remonter une entité JPA dans le cas d’usage

Les signatures matérialisent ces frontières. Par exemple, le cas d’usage accepte ses propres types de commande et de résultat plutôt qu’un Pageable Spring Data ou un objet JSON.

Où se placent Spring et les transactions ?

Les annotations @RestController, @Entity et @Transactional relient l’application à Spring ou JPA. Leur emplacement traduit un compromis entre dépendance au framework et coût de conversion.

Garder @Entity hors du modèle limite la dépendance à JPA, au prix de code de conversion entre modèle métier et objet persisté. Une petite application peut choisir d’annoter directement son modèle pour réduire ces conversions; elle accepte alors le couplage à l’ORM et doit vérifier que ses contraintes ne déforment pas les règles.

Placer @Transactional sur le service d’application est direct mais fait importer une annotation Spring au scénario. Un décorateur transactionnel ou une configuration de composition garde cette dépendance à l’extérieur, au prix de câblage supplémentaire. Le choix dépend de la frontière transactionnelle et du besoin d’isoler ce comportement.

Ce que la frontière rend testable — et ce qu’elle coûte

Pour partir d’une règle et faire évoluer son test, voir TDD en Java : faire évoluer le cycle d’une commande. Un test du cas d’usage peut utiliser un faux OrderRepository et vérifier le résultat ainsi que la sauvegarde demandée. Un test d’intégration vérifie le mapping JPA; un test HTTP vérifie le contrôleur et la conversion des données. Chaque test porte sur une frontière précise. Cette séparation facilite des tests ciblés; elle ne dispense pas de vérifier les adaptateurs ni ne constitue une mesure de productivité ou de qualité.

Ces frontières ont un coût : contrats d’entrée et de sortie, conversions, câblage, gestion transactionnelle, traduction d’erreurs et navigation entre abstractions. Elles sont utiles quand elles séparent des raisons de changement ou permettent de tester indépendamment un scénario et ses adaptateurs. Une application peut garder cette organisation dans un seul déploiement. Le nombre de packages ne mesure pas la qualité : il faut examiner le sens réel des dépendances et les frontières que les tests couvrent.

L’architecture hexagonale aborde une question voisine en nommant les interactions comme des ports et des adaptateurs. Pour comparer les deux vocabulaires et leurs recouvrements, voir Architecture hexagonale en Java : suivre une commande de HTTP à JPA.

Sources techniques

  1. The Clean ArchitectureRobert C. Martin
  2. Domain-Driven Design Reference: Definitions and Pattern SummariesEric Evans / Domain Language