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
À 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