Dans de nombreux projets Java Spring Boot, une impression revient rapidement : on écrit toujours la même chose. Un contrôleur par entité, un service presque identique, des DTOs qui se répètent, une couche repository qui suit le même schéma encore et encore.
À force, le code grossit, mais la valeur ajoutée ne suit pas toujours.
Ce problème a un nom : la violation du principe DRY — Don't Repeat Yourself. Formulé par Andrew Hunt et David Thomas dans The Pragmatic Programmer, il stipule que chaque connaissance ou logique doit avoir une représentation unique et non ambiguë dans un système.
Réécrire le même CRUD dix fois n'est pas seulement fastidieux, c'est une source de divergence silencieuse : une correction faite ici, oubliée là.
Cet article aborde deux problèmes distincts :
- La structure : comment organiser le code pour que le métier reste au centre, grâce à la Clean Architecture et au DDD.
- La répétition : comment éviter de réécrire dix fois le même CRUD, grâce à la généricité.
Ces deux approches sont complémentaires, mais indépendantes. On peut avoir l'une sans l'autre.
Clean Architecture : séparer pour mieux durer
La Clean Architecture repose sur une idée simple mais exigeante : isoler le cœur métier du reste.
On distingue généralement quatre couches :
- Le domaine : contient les règles métier pures
- L'application : orchestre les cas d'usage
- L'interface : expose l'application (REST, UI…)
- L'infrastructure : persistance, frameworks, I/O
L'idée essentielle est que le domaine ne dépend de rien d'extérieur. Ni Spring, ni la base de données, ni les formats d'API. C'est lui qui dicte les règles, les autres couches s'adaptent.
En pratique : le controller ne fait qu'exposer, le service orchestre, et le domaine porte les règles.

DDD : remettre le métier au centre
Le Domain Driven Design insiste sur un point fondamental : le métier doit vivre dans le code, pas être dispersé partout.
Dans un modèle DDD correct :
- Une entité n'est pas un simple conteneur de données.
- Elle protège ses propres invariants.
- Elle est composée de Value Objects qui renforcent le typage et la lisibilité.

Value Object : encapsuler les règles élémentaires
Un Value Object représente une notion du domaine sans identité propre. Il est immuable et valide par construction.
Exemple avec le titre d'un livre :
public final class Title {
private final String value;
public Title(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Title cannot be empty");
}
if (value.length() < 3 || value.length() > 255) {
throw new IllegalArgumentException("Title must be between 3 and 255 characters");
}
this.value = value;
}
public String value() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Title t)) return false;
return value.equals(t.value);
}
@Override
public int hashCode() {
return value.hashCode();
}
}
Un Value Object représentant un titre
Ce n'est pas de la validation dans un service. C'est une règle du domaine encodée dans le type lui-même. Un Title invalide n'existe tout simplement pas.
Entité : agréger les Value Objects
L'entité Book devient plus lisible et plus robuste :
public class Book {
private Long id;
private Title title;
private Author author;
public Book(Long id, String title, Author author) {
this.id = id;
this.title = new Title(title); // la validation est dans Title
if (author == null) {
throw new IllegalArgumentException("A book must have an author");
}
this.author = author;
}
public void rename(String newTitle) {
this.title = new Title(newTitle);
}
public Title title() {
return title;
}
public Author author() {
return author;
}
}
Notre objet domaine Book
La règle est claire : un livre invalide n'existe pas. Le service ne fait pas ce travail, et le controller encore moins. Chaque couche a une responsabilité précise.
Généricité : traiter la répétition sans perdre le sens
Une fois la structure en place, une évidence apparaît : la plupart des CRUD se ressemblent énormément. On crée, on lit, on met à jour, on supprime, peu importe l'entité.
C'est ici que la généricité devient intéressante. Mais attention : elle répond à un problème de répétition, pas de structure. Ne pas confondre les deux.
Un socle commun pour les controllers
public abstract class GenericCrudController<RESPONSE, CREATE, UPDATE, ID> {
protected abstract CrudService<RESPONSE, CREATE, UPDATE, ID> service();
@GetMapping("/{id}")
public ResponseEntity<RESPONSE> getById(@PathVariable ID id) {
return ResponseEntity.ok(service().getById(id));
}
@GetMapping
public ResponseEntity<List<RESPONSE>> getAll() {
return ResponseEntity.ok(service().getAll());
}
@PostMapping
public ResponseEntity<RESPONSE> create(@RequestBody CREATE request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(service().create(request));
}
@PutMapping("/{id}")
public ResponseEntity<RESPONSE> update(@PathVariable ID id,
@RequestBody UPDATE request) {
return ResponseEntity.ok(service().update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable ID id) {
service().delete(id);
return ResponseEntity.noContent().build();
}
}
Un controller généric
Le controller ne contient plus ni logique métier ni logique technique spécifique. Il devient un simple adaptateur.
Spécialisation pour Book
@RestController
@RequestMapping("/books")
public class BookController extends GenericCrudController<
BookResponse,
CreateBookRequest,
UpdateBookRequest,
Long> {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Override
protected CrudService<BookResponse, CreateBookRequest, UpdateBookRequest, Long> service() {
return bookService;
}
}
Le controller pour Book
Le port : l'interface du domaine
Le domaine ne parle pas à JPA directement. Il définit un port, une interface exprimée dans son propre langage :
// couche domaine — aucune dépendance Spring ou JPA
public interface BookRepository {
Book save(Book book);
Optional<Book> findById(Long id);
List<Book> findAll();
void deleteById(Long id);
}
Le service reste responsable du métier
Le service dépend uniquement de BookRepository (le port), jamais de l'adaptateur JPA :
@Service
public class BookService implements CrudService<
BookResponse,
CreateBookRequest,
UpdateBookRequest,
Long> {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
@Override
public BookResponse create(CreateBookRequest request) {
// Author est résolu séparément — BookRepository ne le connaît pas
Author author = new Author(request.authorName());
Book book = new Book(null, request.title(), author);
return toResponse(repository.save(book));
}
@Override
public BookResponse getById(Long id) {
Book book = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Book not found: " + id));
return toResponse(book);
}
@Override
public List<BookResponse> getAll() {
return repository.findAll().stream()
.map(this::toResponse)
.toList();
}
@Override
public BookResponse update(Long id, UpdateBookRequest request) {
Book book = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Book not found: " + id));
book.rename(request.title());
return toResponse(repository.save(book));
}
@Override
public void delete(Long id) {
repository.deleteById(id);
}
private BookResponse toResponse(Book book) {
return new BookResponse(book.id(), book.title().value(), book.author().name());
}
}
Le service traitant les méthodes CRUD pour Book
L'entité JPA : séparée du domaine
L'entité JPA vit dans la couche infrastructure. Elle ne contient aucune règle métier, c'est un simple support de persistance :
@Entity
@Table(name = "books")
public class BookEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String authorName;
protected BookEntity() {}
public BookEntity(Long id, String title, String authorName) {
this.id = id;
this.title = title;
this.authorName = authorName;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthorName() { return authorName; }
}
L'adaptateur : le lien entre domaine et JPA
L'interface Spring Data ne manipule que BookEntity. L'adaptateur fait la traduction dans les deux sens :
public interface BookJpaRepository extends JpaRepository<BookEntity, Long> {}
@Repository
public class BookRepositoryAdapter implements BookRepository {
private final BookJpaRepository jpa;
public BookRepositoryAdapter(BookJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Book save(Book book) {
BookEntity entity = toEntity(book);
return toDomain(jpa.save(entity));
}
@Override
public Optional<Book> findById(Long id) {
return jpa.findById(id).map(this::toDomain);
}
@Override
public List<Book> findAll() {
return jpa.findAll().stream()
.map(this::toDomain)
.toList();
}
@Override
public void deleteById(Long id) {
jpa.deleteById(id);
}
private BookEntity toEntity(Book book) {
return new BookEntity(book.id(), book.title().value(), book.author().name());
}
private Book toDomain(BookEntity entity) {
return new Book(entity.getId(), entity.getTitle(), new Author(entity.getAuthorName()));
}
}
Le domaine ne connaît pas JPA.
JPA ne connaît pas le domaine.
L'adaptateur est le seul à parler les deux langages et c'est intentionnel.
La tension DDD / généricité : un sujet à ne pas esquiver
Il y a une tension réelle entre ces deux approches, et l'ignorer serait une erreur.
DDD dit : chaque agrégat est unique, porteur de règles spécifiques.
La généricité dit : traitons-les de manière uniforme.
Ces deux positions ne sont pas incompatibles, mais elles tirent dans des directions opposées. La résolution est pragmatique :
- La généricité s'applique à la mécanique CRUD (routing, serialisation, réponses HTTP).
- Le DDD s'applique à la logique métier (invariants, règles, agrégats).
Tant que ces deux niveaux restent séparés, ils coexistent sans problème. Le danger apparaît quand on essaie de génériciser les règles métier elles-mêmes et, à ce moment-là, le DDD perd tout son sens.
Points d'attention : la généricité peut devenir un piège
1. Tout ne doit pas être généralisé
Certains cas métier nécessitent des endpoints supplémentaires, des comportements particuliers, ou des règles de validation complexes. Forcer la généricité partout conduit rapidement à un modèle rigide.
Il est tout à fait normal qu'un BookController ajoute des méthodes spécifiques en dehors de ce que propose la classe parente.
2. Le piège de l'abstraction prématurée
Créer une couche générique "au cas où" est souvent une erreur.
La bonne approche est inverse :
- D'abord du code concret.
- Extraction uniquement quand la duplication devient réelle, stable, et coûteuse.
3. Le domaine doit rester lisible
DDD ne signifie pas complexité. Une entité doit rester compréhensible immédiatement. Ajouter des Value Objects pour des concepts sans règle propre alourdit inutilement le code.
La règle de base : un Value Object se justifie quand il encapsule une règle ou un comportement, pas juste pour "faire DDD".
4. La vraie règle : la douleur de la duplication
La généricité devient pertinente uniquement quand la duplication :
- est réelle (pas anticipée),
- est stable (le comportement commun ne changera pas souvent),
- et commence à coûter du temps ou des erreurs.
Conclusion
La Clean Architecture, le DDD et la généricité sont trois outils distincts qui répondent à des problèmes distincts :
- La Clean Architecture organise les dépendances pour protéger le domaine.
- Le DDD donne au métier une place concrète dans le code, via des entités et des Value Objects expressifs.
- La généricité réduit la répétition mécanique une fois que les patterns sont bien établis.
Utilisés ensemble avec discernement, ils permettent de réduire le bruit et de se concentrer sur ce qui compte vraiment : le métier.
Et parfois, la meilleure optimisation reste simplement d'arrêter de réécrire encore et encore la même chose.
Tout le code relatif à cet article est trouvable ici :