Retour aux articles

Concevoir une Clean Architecture pour des projets Spring Boot : principes et implémentation pratique

Concevoir une Clean Architecture pour des projets Spring Boot : principes et implémentation pratique | Laty Gueye Samba - Développeur Full Stack Dakar Sénégal, Expert Java Spring Boot Angular
```html

Concevoir une Clean Architecture pour des projets Spring Boot : principes et implémentation pratique

La Clean Architecture vise à organiser un système de manière à réduire le couplage, favoriser la testabilité et isoler les décisions métier des détails techniques. Dans un projet Spring Boot, l’objectif est de structurer les couches pour que le cœur de l’application reste stable, tandis que les frameworks, la persistance et les interfaces évoluent sans déstabiliser le domaine.

Objectifs principaux

Une architecture propre poursuit généralement les objectifs suivants :

  • Indépendance du domaine : les règles métier ne dépendent pas de Spring, JPA ou d’autres librairies.
  • Frontières explicites : des interfaces décrivent les interactions vers l’extérieur.
  • Organisation par dépendances : les dépendances pointent vers l’intérieur, vers le domaine.
  • Testabilité : les cas d’usage peuvent être testés sans dépendre d’infrastructure.

Principes fondamentaux de Clean Architecture

Dépendances orientées vers le cœur

Le principe central consiste à faire dépendre chaque couche intérieure des couches extérieures uniquement via des abstractions. Le sens recommandé :

  • Interfaces externes (API, Web, messaging)
  • Adapteurs (implémentations techniques)
  • Cas d’usage (application)
  • Domaine (entités, value objects, règles métier)

Règles métier au centre

Les entités, agrégats, value objects et services de domaine contiennent la logique métier. Les règles ne doivent pas être “contaminées” par des annotations Spring ou des préoccupations de persistance.

Ports et Adapters (Hexagonal)

La Clean Architecture s’exprime souvent via le modèle ports & adapters :

  • Port : une interface décrivant une capacité (ex. lire une commande).
  • Adapter : une implémentation qui exécute cette capacité (ex. JPA repository, client REST).

Découpage typique d’un projet Spring Boot

Une structure pratique peut combiner plusieurs modules. Une variante monorepo (multi-modules Gradle/Maven) améliore l’isolation. Exemple :

Structure de packages recommandée

com.example.order ├── application │ ├── usecase │ └── dto ├── domain │ ├── model │ └── service ├── infrastructure │ ├── persistence │ │ └── jpa │ └── web │ └── controller └── adapters (optionnel selon la stratégie) └── outbound

Alternative en multi-modules Maven/Gradle

Approche courante :

  • domain : dépendances minimales (aucune dépendance Spring).
  • application : dépend des interfaces (ports) et du domaine.
  • infrastructure : dépend de application + Spring + JPA.
  • boot (ou app) : configuration Spring, wiring, démarrage.

Implémentation pratique : du domaine au contrôleur

1) Définir les entités et règles métier

Le domaine contient les règles. L’exemple ci-dessous montre une entité Order et un contrôle de cohérence.

package com.example.order.domain.model; import java.util.Objects; import java.util.UUID; public class Order { private final UUID id; private final String customerId; private final OrderStatus status; public Order(UUID id, String customerId, OrderStatus status) { this.id = Objects.requireNonNull(id); this.customerId = Objects.requireNonNull(customerId); this.status = Objects.requireNonNull(status); } public Order approve() { if (this.status != OrderStatus.PENDING) { throw new IllegalStateException("Order must be PENDING to approve"); } return new Order(id, customerId, OrderStatus.APPROVED); } public UUID getId() { return id; } public OrderStatus getStatus() { return status; } } enum OrderStatus { PENDING, APPROVED }

2) Définir les ports (abstractions)

Les ports décrivent les interactions avec l’extérieur. Le domaine ne dépend pas d’eux, mais l’application les consommera.

package com.example.order.application.ports; import com.example.order.domain.model.Order; import java.util.Optional; import java.util.UUID; public interface LoadOrderPort { Optional<Order> loadById(UUID id); } public interface SaveOrderPort { void save(Order order); }

3) Construire les cas d’usage (application)

Les cas d’usage orchestrent l’exécution, en appliquant les règles métier et en appelant les ports. Aucun détail d’infrastructure n’est exposé ici.

package com.example.order.application.usecase; import com.example.order.application.ports.LoadOrderPort; import com.example.order.application.ports.SaveOrderPort; import com.example.order.domain.model.Order; import java.util.UUID; public class ApproveOrderUseCase { private final LoadOrderPort loadOrderPort; private final SaveOrderPort saveOrderPort; public ApproveOrderUseCase(LoadOrderPort loadOrderPort, SaveOrderPort saveOrderPort) { this.loadOrderPort = loadOrderPort; this.saveOrderPort = saveOrderPort; } public void execute(UUID orderId) { Order order = loadOrderPort.loadById(orderId) .orElseThrow(() -> new IllegalArgumentException("Order not found")); Order approved = order.approve(); saveOrderPort.save(approved); } }

4) Implementer les adaptateurs côté infrastructure

Les adaptateurs fournissent la persistance ou l’accès à des systèmes externes. Ils sont souvent annotés avec Spring (ex. @Repository) sans que cela n’atteigne le domaine.

package com.example.order.infrastructure.persistence.jpa; import com.example.order.application.ports.LoadOrderPort; import com.example.order.application.ports.SaveOrderPort; import com.example.order.domain.model.Order; import org.springframework.stereotype.Repository; import java.util.Optional; import java.util.UUID; @Repository public class OrderJpaAdapter implements LoadOrderPort, SaveOrderPort { private final SpringDataOrderRepository springRepo; public OrderJpaAdapter(SpringDataOrderRepository springRepo) { this.springRepo = springRepo; } @Override public Optional<Order> loadById(UUID id) { return springRepo.findById(id).map(this::toDomain); } @Override public void save(Order order) { springRepo.save(toEntity(order)); } private Order toDomain(SpringOrderEntity entity) { return new Order(entity.getId(), entity.getCustomerId(), entity.getStatus()); } private SpringOrderEntity toEntity(Order order) { SpringOrderEntity entity = new SpringOrderEntity(); entity.setId(order.getId()); entity.setCustomerId(order.getCustomerId()); entity.setStatus(order.getStatus()); return entity; } }

5) Exposer via un contrôleur (entrée)

Le contrôleur est un adaptateur d’entrée. Il transforme la requête en paramètres, appelle le cas d’usage, puis renvoie une réponse. La logique de domaine reste dans le domaine.

package com.example.order.infrastructure.web; import com.example.order.application.usecase.ApproveOrderUseCase; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.UUID; @RestController @RequestMapping("/orders") public class OrderController { private final ApproveOrderUseCase approveOrderUseCase; public OrderController(ApproveOrderUseCase approveOrderUseCase) { this.approveOrderUseCase = approveOrderUseCase; } @PostMapping("/{id}/approve") public ResponseEntity<Void> approve(@PathVariable("id") UUID id) { approveOrderUseCase.execute(id); return ResponseEntity.ok().build(); } }

Wiring Spring Boot sans polluer l’architecture

Le “wiring” (configuration des beans) doit être centralisé dans une couche d’assemblage. Dans Spring Boot, une classe de configuration peut créer le cas d’usage et connecter ses ports aux adaptateurs.

Configuration d’assemblage

package com.example.order.boot; import com.example.order.application.ports.LoadOrderPort; import com.example.order.application.ports.SaveOrderPort; import com.example.order.application.usecase.ApproveOrderUseCase; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class UseCaseConfig { @Bean public ApproveOrderUseCase approveOrderUseCase( LoadOrderPort loadOrderPort, SaveOrderPort saveOrderPort ) { return new ApproveOrderUseCase(loadOrderPort, saveOrderPort); } }

Gestion des erreurs et mapping

Une architecture propre clarifie aussi le traitement des erreurs : exceptions métier dans le domaine, mapping en erreurs HTTP dans l’adaptateur d’entrée.

Exemple de mapping via un contrôleur d’avis d’erreurs

package com.example.order.infrastructure.web; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.Map; @RestControllerAdvice public class RestExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", ex.getMessage())); } }

Bonnes pratiques concrètes

Contrôler les dépendances par module

Le moyen le plus fiable de préserver Clean Architecture consiste à définir des règles de dépendances (via multi-modules). Cela empêche l’infrastructure d’être importée accidentellement dans le domaine.

Éviter les annotations dans le domaine

Les annotations JPA, Spring MVC ou validation (au sens strict) doivent rester hors du domaine. Les validations “métier” doivent être portées par les entités et value objects.

Transformer les modèles aux frontières

Les DTO d’API ne doivent pas être les entités de domaine. Une transformation explicite aux frontières améliore la stabilité du domaine.

Checklist pour valider une Clean Architecture

  • Le domaine ne dépend d’aucun framework (pas de Spring/JPA).
  • Les cas d’usage dépendent d’interfaces (ports).
  • Les adaptateurs implémentent les ports et contiennent les annotations techniques.
  • Le contrôleur se limite à l’orchestration d’entrée (mapping requête/réponse).
  • Le wiring Spring reste centralisé dans une couche “boot/assembly”.

Conclusion

Concevoir une Clean Architecture pour des projets Spring Boot permet de stabiliser le cœur métier, de réduire le couplage et d’améliorer la testabilité. En combinant des ports & adapters, une séparation claire entre domaine et , et un wiring Spring dédié, l’application devient plus robuste face à l’évolution des technologies.

À 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

© 2026 Laty Gueye Samba.