Java · Architecture logicielle

Architecture hexagonale en Java : suivre une commande de HTTP à JPA

Suivre une commande du contrôleur HTTP aux ports et à l’adaptateur JPA puis tester les frontières Spring.

Quand une règle métier change, elle se retrouve parfois dispersée entre le contrôleur, le service et les classes JPA. L’architecture hexagonale aide à séparer les échanges du cœur applicatif : des ports décrivent les opérations offertes ou requises; des adaptateurs traduisent les protocoles HTTP, les tests et la persistance.

Les exemples suivent un scénario fictif : une commande arrive avec des lignes SKU/quantité, passe de DRAFT à PLACED après validation puis est sauvegardée. Les extraits présentent les contrats et la séquence de traitement; imports, configuration Spring et types auxiliaires sont omis.

Inspecter une frontière avant de déplacer le code

Pour lire les exemples, vous devez connaître les interfaces et imports Java. Dans votre projet, choisissez un seul scénario et ouvrez son port entrant et son contrat de persistance, ici PlaceOrder et OrderRepository. Relevez leurs paramètres, résultats et imports, puis ouvrez les types exposés : un contrat peut cacher une dépendance technique dans un type auxiliaire.

Si le port expose ResponseEntity, une entité JPA ou un type Spring Data, notez l’échange à traduire dans un adaptateur. Si ses types parlent uniquement de commande, de lignes et d’identifiant, suivez ensuite le service pour vérifier qui implémente la persistance. Cette inspection donne une première frontière à examiner; elle ne couvre pas toutes les dépendances du projet. Les extraits ci-dessous sont partiels et ne constituent pas un projet compilable.

Le flux passe par des ports

Une requête HTTP pilote le cas d’usage. Celui-ci crée la commande, demande à l’agrégat de la placer et utilise un contrat de persistance. Ce parcours peut être dessiné ainsi :

HTTP
  → contrôleur (adaptateur entrant)
  → PlaceOrder (port entrant)
  → PlaceOrderService
  → OrderRepository (port sortant)
  → JpaOrderRepository (adaptateur sortant)
  → Spring Data / base

Alistair Cockburn présente les ports comme des protocoles d’interaction avec l’application et les adaptateurs comme des traductions vers ou depuis une technologie. Une application peut être pilotée par HTTP, un test, un batch ou un autre acteur. L’orientation intérieur/extérieur (inside/outside) compte plus que le haut ou le bas du dessin (article original de Cockburn, rapport HaT 2005.02).

En Java, on représente souvent un port par une interface. Sa responsabilité tient au protocole et à l’échange qu’il décrit.

interface PlaceOrder {
    PlaceOrderResult handle(PlaceOrderCommand command);
}

interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

PlaceOrder est le port entrant : il expose un cas d’usage. OrderRepository est le port sortant : le service en a besoin pour sauvegarder ou retrouver le modèle. L’isolation dépend du contrat et des types qu’il expose.

Le service applicatif orchestre le cas d’usage

Le service reçoit le port sortant et un fournisseur d’identifiants. Le modèle crée une commande DRAFT; place() vérifie qu’elle contient au moins une ligne puis la passe à PLACED. Le service sauvegarde seulement après cette transition.

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

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

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

OrderLine(sku, quantity) refuse un SKU vide et une quantité inférieure ou égale à zéro lors de sa création. Order.create(id, lines) produit une commande DRAFT et order.place() vérifie la non-vacuité avant de passer à PLACED. Le modèle protège ces règles quel que soit l’adaptateur utilisé.

Le cœur dépend du contrat OrderRepository. L’adaptateur JPA réalise ce contrat. Spring assemble les implémentations au démarrage; cette composition ne change pas le sens des dépendances dans le code source.

L’adaptateur HTTP traduit la requête

Le contrôleur est un adaptateur entrant. Il convertit la représentation JSON en PlaceOrderCommand, délègue au port puis traduit le résultat en réponse HTTP.

@RestController
@RequestMapping("/orders")
final class PlaceOrderController {
    private final PlaceOrder placeOrder;

    PlaceOrderController(PlaceOrder placeOrder) {
        this.placeOrder = placeOrder;
    }

    @PostMapping
    ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
        var command = new PlaceOrderCommand(
            request.lines().stream()
                .map(line -> new OrderLineInput(line.sku(), line.quantity()))
                .toList()
        );
        var result = placeOrder.handle(command);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(new OrderResponse(result.orderId().value()));
    }
}

CreateOrderRequest et OrderResponse représentent ici le contrat HTTP. Le contrôleur traduit HTTP en commande et le résultat en réponse; OrderEntity et la transition métier restent dans leurs propres frontières. Une erreur de validation doit être traduite en réponse API selon le contrat HTTP de l’application.

L’adaptateur JPA traduit vers la persistance

Le port parle avec les types du domaine. L’adaptateur convertit l’agrégat en entité JPA et reconstitue le modèle lors d’une lecture.

@Repository
final class JpaOrderRepository implements OrderRepository {
    private final SpringDataOrderJpaRepository rows;

    JpaOrderRepository(SpringDataOrderJpaRepository rows) {
        this.rows = rows;
    }

    @Override
    public void save(Order order) {
        rows.save(OrderEntity.from(order));
    }

    @Override
    public Optional<Order> findById(OrderId id) {
        return rows.findById(id.value()).map(OrderEntity::toDomain);
    }
}

OrderEntity.from et toDomain illustrent les conversions à réaliser pour l’identifiant, l’état et les lignes. Le périmètre des transactions et leurs tests dépendent de l’application; les choix de placement Spring sont détaillés dans Clean Architecture en Java : placer les règles métier face à Spring.

Tester chaque frontière

Pour construire progressivement le test d’une règle, consultez TDD en Java : faire évoluer le cycle d’une commande. Chaque test répond à une question précise. Le tableau associe chaque cible à sa vérification et au test complémentaire; le code qui suit montre un test de tranche HTTP et un test de persistance.

Cible Vérification Test complémentaire
Domaine et cas d’usage Invariants DRAFT → PLACED, dépôt mémoire et résultat Tester le mapping d’un adaptateur
Adaptateur HTTP JSON, statut, mapping vers le port Tester la collaboration avec le cas d’usage réel
Adaptateur JPA Sauvegarde, relecture et conversion du modèle Exécuter le test sur la base cible
Assemblage Câblage des vrais composants sur un scénario ciblé Ajouter des scénarios pour les données et configurations de l’application

Dans un test de tranche MVC, @WebMvcTest et MockMvc permettent d’exercer les routes et le mapping. Le port entrant peut être remplacé pour garder le scénario centré sur HTTP :

@WebMvcTest(PlaceOrderController.class)
class PlaceOrderHttpAdapterTest {
    @Autowired MockMvc mvc;
    @MockitoBean PlaceOrder placeOrder;

    @Test
    void traduitLaRequeteEtRetourneLaCommandeCreee() throws Exception {
        given(placeOrder.handle(any(PlaceOrderCommand.class)))
            .willReturn(new PlaceOrderResult(new OrderId("order-42")));

        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"lines\":[{\"sku\":\"BOOK-7\",\"quantity\":2}]}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").value("order-42"));

        verify(placeOrder).handle(new PlaceOrderCommand(
            List.of(new OrderLineInput("BOOK-7", 2))
        ));
    }
}

Les imports statiques et objets de transport sont omis. Les annotations disponibles et les options de tranche dépendent des versions de Spring Boot et Spring Test du projet; il faut les adapter à cette configuration. Un test d’intégration peut, lui, assembler le vrai PlaceOrderService avec le dépôt et l’infrastructure de test pour vérifier leur collaboration.

Pour l’adaptateur JPA, une tranche @DataJpaTest peut importer l’adaptateur et vérifier un aller-retour par la base de test. L’exemple force l’écriture et vide le contexte de persistance avant de relire :

@DataJpaTest
@Import(JpaOrderRepository.class)
class JpaOrderRepositoryAdapterTest {
    @Autowired OrderRepository orders;
    @Autowired TestEntityManager entityManager;

    @Test
    void sauvegardeEtReconstruitUneCommandePlacee() {
        var id = new OrderId("order-42");
        var order = Order.create(id, List.of(new OrderLine("BOOK-7", 2)));
        order.place();

        orders.save(order);
        entityManager.flush();
        entityManager.clear();

        var restored = orders.findById(id).orElseThrow();
        assertEquals(id, restored.id());
        assertEquals(OrderStatus.PLACED, restored.status());
        assertEquals(order.lines(), restored.lines());
    }
}

La configuration suppose que l’adaptateur, les entités et le dépôt Spring Data sont disponibles dans la tranche. Le résultat porte sur le mapping et le comportement du moteur choisi; la compatibilité avec la base cible reste à vérifier. Spring Boot documente les annotations de tranche et MockMvc (documentation officielle).

Recouvrement avec Clean Architecture

L’architecture hexagonale et Clean Architecture partagent le souci de garder les règles du cœur indépendantes des détails techniques. Elles peuvent décrire le même système : OrderRepository est un port sortant et aussi une frontière qui maintient la dépendance vers le cœur.

Cockburn met l’accent sur les interactions : les acteurs qui pilotent l’application, les ports qu’elle expose et les adaptateurs qui traduisent chaque technologie. Robert C. Martin met l’accent sur la direction des dépendances et l’organisation des règles en cercles, des entités et cas d’usage vers les adaptateurs et frameworks. Il présente les cercles comme un schéma, sans imposer un nombre précis de couches (« The Clean Architecture », 2012).

Les deux vocabulaires se complètent. La frontière du port peut limiter les changements requis dans le cœur si un adaptateur évolue. Une migration garde toutefois du travail : adapter l’infrastructure, reprendre le câblage et les tests puis vérifier à nouveau les comportements. Le schéma doit se refléter dans les imports et les objets qui traversent réellement la frontière.

Choisir les frontières utiles

Les ports sont utiles quand une dépendance change indépendamment du cas d’usage, quand plusieurs acteurs pilotent le même scénario ou quand la règle métier doit se tester sans l’infrastructure. Pour une application CRUD simple, des interfaces autour de chaque classe peuvent ajouter du code sans isoler de décision. Introduire une frontière là où elle clarifie un échange ou permet de vérifier un risque identifié garde le système lisible.

Le DDD aide à décider quel modèle métier se trouve derrière le port et où ses règles s’appliquent; l’hexagonale décrit comment ce modèle communique avec ses acteurs et mécanismes. Voir DDD en Java : faire coïncider le modèle et le langage métier pour les bounded contexts, agrégats et autres patterns métier.

Sources techniques

  1. Hexagonal architecture the original 2005 articleAlistair Cockburn
  2. The Clean ArchitectureRobert C. Martin
  3. Domain-Driven Design Reference: Definitions and Pattern SummariesEric Evans / Domain Language
  4. Testing Spring Boot ApplicationsSpring Boot team