🎯 OBJECTIF
Comprendre comment :
🧠 MODÈLE MENTAL
Un controller Spring Boot est une frontière technique, pas un endroit pour la logique métier. Son seul rôle : traduire HTTP → Domain et Domain → HTTP. Il reçoit une requête, valide le format, délègue au service, et retourne un code HTTP approprié.
La couche Web est responsable du contrat d'API — les DTO, les codes HTTP, la validation des entrées. Dès qu'un service ou une entité JPA est directement exposé, le couplage devient destructeur : tout changement de modèle casse l'API, et tout changement d'API risque de corrompre la base. Le DTO est la muraille qui sépare les deux.
Équation : controller fin + DTO + @Valid + @RestControllerAdvice = API maintenable.
@RestController
@RequestMapping("/api/stocks")
class StockController {
@GetMapping("/{id}")
StockDto get(@PathVariable Long id) { ... }
@PostMapping
StockDto create(@Valid @RequestBody CreateStockRequest req) { ... }
@PutMapping("/{id}")
StockDto update(@PathVariable Long id,
@Valid @RequestBody UpdateStockRequest req) { ... }
@DeleteMapping("/{id}")
void delete(@PathVariable Long id) { ... }
}javaRègles d'or pour les URL
/stocks, /orders, /usersGET /stocks/{id} et non /getStock/stocks/{id}/movements// @PathVariable — segment d'URL
// GET /stocks/42
@GetMapping("/{id}")
StockDto get(@PathVariable Long id) { ... }
// @RequestParam — query string
// GET /stocks?status=FREE&zone=A1
@GetMapping
List<StockDto> search(
@RequestParam String status,
@RequestParam(required = false) String zone
) { ... }
// @RequestBody — payload JSON
// POST /stocks { "productId": "P1", "quantity": 10 }
@PostMapping
StockDto create(@Valid @RequestBody CreateStockRequest req) { ... }
// @RequestHeader — header HTTP
@GetMapping
StockDto getWithCorrelation(
@RequestHeader("X-Correlation-Id") String cid
) { ... }
// Combinaison libre
@PostMapping("/{id}/move")
void move(
@PathVariable Long id,
@RequestParam String target,
@Valid @RequestBody MoveRequest body
) { ... }java// ✓ Records Java — immutables, compacts, intention claire
public record CreateStockRequest(
@NotBlank String productId,
@Positive BigDecimal quantity
) {}
public record StockDto(
Long id,
String productId,
BigDecimal quantity,
String status
) {}javaSéparation des couches :
Controller → DTO
Service → Domain
Persistence → Entity
🚨 Exposer une entité JPA directement
public record CreateStockRequest(
@NotBlank // non null, non vide, non whitespace
String productId,
@Positive // > 0
BigDecimal quantity,
@Size(min = 2, max = 50)
String label,
@Email
String contactEmail
) {}java// ✓ @Valid OBLIGATOIRE — sans ça, toutes les annotations ci-dessus sont ignorées
@PostMapping
StockDto create(@Valid @RequestBody CreateStockRequest req) { ... }javaSi la validation échoue → Spring lève MethodArgumentNotValidException → à catcher dans @RestControllerAdvice.
@RestControllerAdvice
class ApiErrors {
// Validation Jakarta
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
return ResponseEntity.badRequest().body(Map.of("errors", errors));
}
// Business exception
@ExceptionHandler(StockNotFoundException.class)
ResponseEntity<?> handleNotFound(StockNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
// Catch-all
@ExceptionHandler(Exception.class)
ResponseEntity<?> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Internal server error"));
}
}java🚨 Jamais de try/catch dans les controllers
Toutes les exceptions doivent remonter vers @RestControllerAdvice. Un try/catch dans un controller duplique la logique de gestion d'erreur, brise la cohérence du format de réponse, et masque les erreurs aux outils d'observabilité.
<!-- pom.xml -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.x</version>
</dependency>xml@Operation(summary = "Get stock by ID")
@GetMapping("/{id}")
StockDto get(
@Parameter(description = "Stock ID") @PathVariable Long id
) { ... }
public record StockDto(
Long id,
@Schema(description = "Product identifier") String productId,
@Schema(description = "Available quantity") BigDecimal quantity
) {}javaEndpoints exposés par Springdoc :
/swagger-ui.html — interface interactive/v3/api-docs — spécification OpenAPI JSONOption 1 — Par ressource (classique, CRUD)
StockController // tout ce qui concerne /stocks
OrderController // tout ce qui concerne /ordersjavaOption 2 — Par use case (CQRS léger)
StockQueryController // GET /stocks/**
StockCommandController // POST/PUT/DELETE /stocks/**javaOption 3 — Hexagonal
Controllers = adapters
Web → Application → Domain
Le controller appelle un UseCase interface, le domaine ne connaît pas Spring.
🚨 Anti-patterns
⚡ TL;DR — chaque concept en une ligne
@RestController + mappings
✓ @GetMapping, @PostMapping, etc. sur une classe @RestController exposent des endpoints REST avec sérialisation/désérialisation JSON automatique (Jackson).
⚠ URL au pluriel, sans verbe — le verbe est dans la méthode HTTP. /stocks/{id} pas /getStock.
Paramètres (@PathVariable / @RequestParam / @RequestBody)
✓ @PathVariable = segment d'URL, @RequestParam = query string, @RequestBody = payload JSON, @RequestHeader = header HTTP. Tous combinables.
⚠ @RequestBody sans @Valid ne valide rien — les contraintes Jakarta sont silencieusement ignorées.
DTO obligatoires ✓ Séparation Controller → DTO, Service → Domain, Persistence → Entity. Les records Java sont idéaux : immutables, compacts. ⚠ Exposer une entité JPA directement provoque lazy loading accidentel, fuite du modèle interne et couplage DB ↔ API.
@Valid + Jakarta Validation
✓ @Valid sur @RequestBody active la validation — Spring lève MethodArgumentNotValidException si une contrainte est violée.
⚠ Sans @Valid, @NotBlank, @Positive, etc. sont décoratives — aucun effet, aucun log, aucune erreur.
@RestControllerAdvice ✓ Centralise la gestion des exceptions — un seul endroit pour transformer les exceptions en réponses HTTP cohérentes. ⚠ Jamais de try/catch dans les controllers — toutes les exceptions doivent remonter vers l'advice.
🎓 À retenir
@Valid est non-négociable — sans lui, Jakarta Validation ne fait rien. Une seule annotation oubliée, et des données invalides entrent dans la base silencieusement.equals/hashCode/toString gratuits, impossible d'accumuler de l'état mutable, intention claire de "objet de transfert".