Retour aux articles

Application de l'Architecture Hexagonale (Ports & Adapters) à une application Spring Boot 3.x

Application de l'Architecture Hexagonale (Ports & Adapters) à une application Spring Boot 3.x | Laty Gueye Samba - Développeur Full Stack Dakar Sénégal, Expert Java Spring Boot Angular
Application de l'Architecture Hexagonale (Ports & Adapters) à une application Spring Boot 3.x

Application de l'Architecture Hexagonale (Ports & Adapters) à une application Spring Boot 3.x

Dans l'écosystème du développement logiciel moderne, la construction d'applications robustes, maintenables et évolutives représente un défi constant. Les architectures traditionnelles, bien que familières, peuvent souvent conduire à un couplage étroit entre la logique métier et les détails d'infrastructure, rendant les tests complexes et l'évolution des systèmes ardue.

L'Architecture Hexagonale, également connue sous le nom de Ports & Adapters, émerge comme une solution puissante pour adresser ces problématiques. Elle vise à isoler le cœur de l'application (la logique métier) de ses mécanismes d'interaction externes, assurant ainsi une meilleure résilience aux changements. Pour les développeurs Full Stack comme Laty Gueye Samba, basé à Dakar, maîtriser de telles architectures est essentiel pour livrer des solutions de haute qualité, particulièrement dans des contextes exigeants où les applications doivent s'adapter rapidement aux besoins changeants.

Cet article explore les principes de l'Architecture Hexagonale et démontre comment elle peut être concrètement appliquée à une application Spring Boot 3.x, offrant une voie vers une Clean Architecture et des systèmes plus flexibles et testables.

Comprendre l'Architecture Hexagonale (Ports & Adapters)

L'Architecture Hexagonale, introduite par Alistair Cockburn, propose une approche radicale pour organiser le code en plaçant la logique métier au centre de l'application. Le concept clé est de protéger ce "cœur" en le délimitant clairement de tout ce qui est externe. Cela inclut l'interface utilisateur, les bases de données, les systèmes de messagerie, les services externes, etc.

Les Ports

Un Port est une interface, un contrat défini par le cœur de l'application. Il représente un point d'entrée ou de sortie par lequel le cœur communique avec le monde extérieur. On distingue généralement deux types de ports :

  • Ports Primaires (ou Driving Ports) : Ce sont les interfaces que l'application expose pour être utilisée. Par exemple, une interface ProductServicePort définissant les opérations qu'une application peut réaliser sur des produits (créer, récupérer, mettre à jour). Les acteurs externes (comme une API REST ou une interface utilisateur) "conduisent" l'application à travers ces ports.
  • Ports Secondaires (ou Driven Ports) : Ce sont les interfaces dont l'application a besoin pour fonctionner, pour interagir avec des services externes. Par exemple, une interface ProductRepositoryPort pour la persistance des produits. L'application est "conduite" par ces ports vers des systèmes de support.

Les Adapters

Un Adapter est une implémentation concrète d'un Port. Il s'agit du code qui permet au monde extérieur de se "brancher" sur l'application via un Port, ou à l'application de se "brancher" sur un service externe via un Port. Les adapters traduisent les informations du format externe vers le format du domaine et vice-versa.

  • Adapters Primaires (ou Driving Adapters) : Ils implémentent les ports primaires. Exemples : un contrôleur REST, une interface utilisateur graphique, un client de message. Ils invoquent les opérations définies par les ports primaires.
  • Adapters Secondaires (ou Driven Adapters) : Ils implémentent les ports secondaires. Exemples : un adaptateur de base de données (DAO/Repository JPA), un client HTTP pour un service externe, un producteur de messages Kafka. Ils fournissent les détails d'implémentation dont le cœur a besoin sans polluer le domaine.

Cette séparation stricte garantit que le cœur de l'application n'a aucune connaissance des détails techniques de son environnement, favorisant la testabilité (en remplaçant facilement les adapters par des mocks) et la flexibilité face aux changements technologiques.

Structuration d'un Projet Spring Boot 3.x avec l'Architecture Hexagonale

L'application de l'Architecture Hexagonale dans un projet Spring Boot 3.x peut être organisée de manière logique à travers la structure des packages. L'objectif est de rendre manifeste la séparation entre le domaine, l'application et l'infrastructure.

Une structure de packages courante qui reflète les principes de Ports & Adapters pourrait ressembler à ceci :


src/main/java/com/latygueyesamba/hexagonal
├── domain              // Le cœur de l'application : entités, services métier, ports (interfaces)
│   ├── model           // Les objets métier (agrégats, entités, Value Objects)
│   │   └── Product.java
│   ├── port            // Les interfaces définissant les opérations du domaine (Ports)
│   │   ├── in          // Ports primaires (ce que l'application offre)
│   │   │   └── ProductServicePort.java
│   │   └── out         // Ports secondaires (ce dont l'application a besoin)
│   │       └── ProductRepositoryPort.java
│   └── service         // Implémentations concrètes de la logique métier (Use Cases)
│       └── ProductServiceImpl.java
├── application         // Orchestration et implémentation des ports primaires
│   ├── service         // Contient souvent les implémentations des services métier de haut niveau
│   │   └── ProductApplicationService.java // Peut implémenter ProductServicePort
│   └── usecase         // Des interfaces pour les cas d'utilisation (peut être fusionné avec port.in)
│       └── CreateProductUseCase.java
│       └── GetProductUseCase.java
├── infrastructure      // Les Adapters qui connectent le cœur au monde extérieur
│   ├── adapter
│   │   ├── primary     // Adapters Primaires (UI, REST API, Message Listeners)
│   │   │   └── web     // Couche Web : Contrôleurs REST
│   │   │       └── ProductController.java
│   │   └── secondary   // Adapters Secondaires (Persistance, services externes)
│   │       ├── persistence // Couche de persistance : Adapters JPA, etc.
│   │       │   ├── entity    // Entités JPA (différentes des modèles de domaine)
│   │       │   │   └── ProductJpaEntity.java
│   │       │   ├── repository // Repositories Spring Data JPA
│   │       │   │   └── JpaProductSpringRepository.java
│   │       │   └── JpaProductAdapter.java // Implémente ProductRepositoryPort
│   │       └── external      // Clients HTTP, services de messagerie, etc.
│   │           └── ExternalServiceClient.java
│   └── configuration   // Configuration Spring (Beans, Mappers)
│       └── DomainConfiguration.java
└── HexagonalApplication.java // Classe principale Spring Boot
    

Cette structure garantit que les dépendances vont toujours de l'extérieur vers l'intérieur (de l'infrastructure vers le domaine), et que le domain ne dépend de rien d'autre que de lui-même ou des interfaces qu'il définit. C'est un pilier de la Clean Architecture.

Exemples Concrets de Ports et Adapters en Spring Boot

Pour illustrer concrètement l'application de l'Architecture Hexagonale avec Spring Boot 3.x, examinons un exemple simple de gestion de produits.

1. Le Domaine : Ports et Logique Métier

Le cœur de l'application, indépendant de Spring Boot et de la base de données.

Modèle de Domaine


// src/main/java/com/latygueyesamba/hexagonal/domain/model/Product.java
package com.latygueyesamba.hexagonal.domain.model;

import java.math.BigDecimal;
import java.util.UUID;

public class Product {
    private UUID id;
    private String name;
    private String description;
    private BigDecimal price;

    // Constructeur, getters, setters, equals, hashCode...
    public Product(UUID id, String name, String description, BigDecimal price) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
    }

    // Getters
    public UUID getId() { return id; }
    public String getName() { return name; }
    public String getDescription() { return description; }
    public BigDecimal getPrice() { return price; }

    // Setters (pour la désérialisation ou la modification contrôlée)
    public void setName(String name) { this.name = name; }
    public void setDescription(String description) { this.description = description; }
    public void setPrice(BigDecimal price) { this.price = price; }
}
    

Ports

Définition des interfaces que l'application offre (port primaire) et dont elle a besoin (port secondaire).


// src/main/java/com/latygueyesamba/hexagonal/domain/port/in/ProductServicePort.java
package com.latygueyesamba.hexagonal.domain.port.in;

import com.latygueyesamba.hexagonal.domain.model.Product;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

// Port primaire : Définit les cas d'utilisation pour gérer les produits
public interface ProductServicePort {
    Product createProduct(Product product);
    Optional<Product> getProductById(UUID id);
    List<Product> getAllProducts();
    Product updateProduct(UUID id, Product product);
    void deleteProduct(UUID id);
}
    

// src/main/java/com/latygueyesamba/hexagonal/domain/port/out/ProductRepositoryPort.java
package com.latygueyesamba.hexagonal.domain.port.out;

import com.latygueyesamba.hexagonal.domain.model.Product;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

// Port secondaire : Définit les opérations de persistance pour les produits
public interface ProductRepositoryPort {
    Product save(Product product);
    Optional<Product> findById(UUID id);
    List<Product> findAll();
    void deleteById(UUID id);
}
    

Logique Métier (Service de Domaine)

Implémentation du port primaire, dépendant du port secondaire.


// src/main/java/com/latygueyesamba/hexagonal/domain/service/ProductServiceImpl.java
package com.latygueyesamba.hexagonal.domain.service;

import com.latygueyesamba.hexagonal.domain.model.Product;
import com.latygueyesamba.hexagonal.domain.port.in.ProductServicePort;
import com.latygueyesamba.hexagonal.domain.port.out.ProductRepositoryPort;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class ProductServiceImpl implements ProductServicePort {

    private final ProductRepositoryPort productRepositoryPort;

    public ProductServiceImpl(ProductRepositoryPort productRepositoryPort) {
        this.productRepositoryPort = productRepositoryPort;
    }

    @Override
    public Product createProduct(Product product) {
        if (product.getId() == null) {
            product = new Product(UUID.randomUUID(), product.getName(), product.getDescription(), product.getPrice());
        }
        return productRepositoryPort.save(product);
    }

    @Override
    public Optional<Product> getProductById(UUID id) {
        return productRepositoryPort.findById(id);
    }

    @Override
    public List<Product> getAllProducts() {
        return productRepositoryPort.findAll();
    }

    @Override
    public Product updateProduct(UUID id, Product product) {
        return productRepositoryPort.findById(id).map(existingProduct -> {
            existingProduct.setName(product.getName());
            existingProduct.setDescription(product.getDescription());
            existingProduct.setPrice(product.getPrice());
            return productRepositoryPort.save(existingProduct);
        }).orElseThrow(() -> new IllegalArgumentException("Product not found with ID: " + id));
    }

    @Override
    public void deleteProduct(UUID id) {
        productRepositoryPort.deleteById(id);
    }
}
    

2. Infrastructure : Adapters Spring Boot

Les adapters gèrent la communication avec le monde extérieur, sans que le domaine n'en soit conscient.

Adapter Primaire (API REST)


// src/main/java/com/latygueyesamba/hexagonal/infrastructure/adapter/primary/web/ProductController.java
package com.latygueyesamba.hexagonal.infrastructure.adapter.primary.web;

import com.latygueyesamba.hexagonal.domain.model.Product;
import com.latygueyesamba.hexagonal.domain.port.in.ProductServicePort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductServicePort productServicePort;

    public ProductController(ProductServicePort productServicePort) {
        this.productServicePort = productServicePort;
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        Product createdProduct = productServicePort.createProduct(product);
        return new ResponseEntity<>(createdProduct, HttpStatus.CREATED);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProductById(@PathVariable UUID id) {
        return productServicePort.getProductById(id)
                .map(product -> new ResponseEntity<>(product, HttpStatus.OK))
                .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @GetMapping
    public ResponseEntity<List<Product>> getAllProducts() {
        List<Product> products = productServicePort.getAllProducts();
        return new ResponseEntity<>(products, HttpStatus.OK);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable UUID id, @RequestBody Product product) {
        try {
            Product updatedProduct = productServicePort.updateProduct(id, product);
            return new ResponseEntity<>(updatedProduct, HttpStatus.OK);
        } catch (IllegalArgumentException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable UUID id) {
        productServicePort.deleteProduct(id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
}
    

Adapter Secondaire (Persistance JPA)

L'entité JPA peut être différente du modèle de domaine pour des raisons de mapping.


// src/main/java/com/latygueyesamba/hexagonal/infrastructure/adapter/secondary/persistence/entity/ProductJpaEntity.java
package com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.UUID;

@Entity
@Table(name = "products")
public class ProductJpaEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    private String name;
    private String description;
    private BigDecimal price;

    // Constructeurs, getters, setters
    public ProductJpaEntity() {}

    public ProductJpaEntity(UUID id, String name, String description, BigDecimal price) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
    }

    // Getters
    public UUID getId() { return id; }
    public String getName() { return name; }
    public String getDescription() { return description; }
    public BigDecimal getPrice() { return price; }

    // Setters
    public void setId(UUID id) { this.id = id; }
    public void setName(String name) { this.name = name; }
    public void setDescription(String description) { this.description = description; }
    public void setPrice(BigDecimal price) { this.price = price; }
}
    

// src/main/java/com/latygueyesamba/hexagonal/infrastructure/adapter/secondary/persistence/repository/JpaProductSpringRepository.java
package com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence.repository;

import com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence.entity.ProductJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;

// Repository Spring Data JPA "standard"
public interface JpaProductSpringRepository extends JpaRepository<ProductJpaEntity, UUID> {
}
    

// src/main/java/com/latygueyesamba/hexagonal/infrastructure/adapter/secondary/persistence/JpaProductAdapter.java
package com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence;

import com.latygueyesamba.hexagonal.domain.model.Product;
import com.latygueyesamba.hexagonal.domain.port.out.ProductRepositoryPort;
import com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence.entity.ProductJpaEntity;
import com.latygueyesamba.hexagonal.infrastructure.adapter.secondary.persistence.repository.JpaProductSpringRepository;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

// Adapter secondaire : Implémente le port de persistance en utilisant Spring Data JPA
@Component
public class JpaProductAdapter implements ProductRepositoryPort {

    private final JpaProductSpringRepository jpaProductSpringRepository;

    public JpaProductAdapter(JpaProductSpringRepository jpaProductSpringRepository) {
        this.jpaProductSpringRepository = jpaProductSpringRepository;
    }

    @Override
    public Product save(Product product) {
        ProductJpaEntity entity = toJpaEntity(product);
        ProductJpaEntity savedEntity = jpaProductSpringRepository.save(entity);
        return toDomainModel(savedEntity);
    }

    @Override
    public Optional<Product> findById(UUID id) {
        return jpaProductSpringRepository.findById(id).map(this::toDomainModel);
    }

    @Override
    public List<Product> findAll() {
        return jpaProductSpringRepository.findAll().stream()
                .map(this::toDomainModel)
                .collect(Collectors.toList());
    }

    @Override
    public void deleteById(UUID id) {
        jpaProductSpringRepository.deleteById(id);
    }

    // Mappers entre modèle de domaine et entité JPA
    private ProductJpaEntity toJpaEntity(Product product) {
        return new ProductJpaEntity(product.getId(), product.getName(), product.getDescription(), product.getPrice());
    }

    private Product toDomainModel(ProductJpaEntity entity) {
        return new Product(entity.getId(), entity.getName(), entity.getDescription(), entity.getPrice());
    }
}
    

3. Configuration Spring (Injection de Dépendances)

La configuration de Spring Boot assemble les pièces, en injectant les adapters dans le domaine via les ports.


// src/main/java/com/latygueyesamba/hexagonal/infrastructure/configuration/DomainConfiguration.java
package com.latygueyesamba.hexagonal.infrastructure.configuration;

import com.latygueyesamba.hexagonal.domain.port.in.ProductServicePort;
import com.latygueyesamba.hexagonal.domain.port.out.ProductRepositoryPort;
import com.latygueyesamba.hexagonal.domain.service.ProductServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DomainConfiguration {

    // Crée le bean pour l'implémentation du service de domaine, en injectant son port de persistance
    @Bean
    public ProductServicePort productServicePort(ProductRepositoryPort productRepositoryPort) {
        return new ProductServiceImpl(productRepositoryPort);
    }

    // Le JpaProductAdapter est un @Component et est automatiquement scanné par Spring
    // et sera injecté ici en tant que ProductRepositoryPort
}
    

Cette structure garantit que ProductServiceImpl ne connaît rien de Spring Data JPA ou des contrôleurs REST. Il interagit uniquement avec les interfaces (Ports). Les Adapters, situés dans la couche d'infrastructure, sont chargés de traduire les appels et les données vers et depuis le domaine.

Point de vue : développeur full stack à Dakar

Pour un développeur travaillant sur des systèmes de gestion hospitalière, des applications de gestion des risques ou des systèmes ERP complexes, la maîtrise de l'Architecture Hexagonale et de la Clean Architecture représente un avantage concurrentiel réel sur le marché technologique africain, en pleine expansion. Laty Gueye Samba, Développeur Full Stack à Dakar, constate l'importance d'une conception robuste pour des applications qui doivent évoluer rapidement et rester performantes dans des environnements exigeants. L'application de ces principes avec Java Spring Boot permet de construire des fondations solides pour des projets d'envergure.

Conclusion

L'Architecture Hexagonale (Ports & Adapters), en plaçant le cœur métier au centre et en isolant l'infrastructure, offre une approche puissante pour développer des applications Spring Boot 3.x qui sont intrinsèquement plus maintenables, testables et évolutives. Cette architecture favorise un faible couplage et une haute cohésion, des qualités essentielles pour des systèmes durables.

L'expertise en Java Spring Boot combinée à une compréhension approfondie de principes architecturaux tels que l'Architecture Hexagonale permet aux développeurs comme Laty Gueye Samba, Développeur Full Stack à Dakar, de concevoir et de réaliser des solutions logicielles de pointe, capables de répondre aux défis des projets les plus ambitieux.

Pour approfondir vos connaissances sur ce sujet et sur Spring Boot, il est recommandé de consulter les ressources officielles :

À propos de l'auteur

Laty Gueye Samba est développeur Full Stack basé à Dakar, Sénégal. Spécialiste des écosystèmes Java / Spring Boot et Angular.

Contact : latygueyesamba@gmail.com  |  Dakar, Sénégal