Série « Spring MVC et Spring Boot » : partie 1 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring (cette partie)
- Validation, erreurs et pagination
- Sécurité JWT et tests
Partie suivante : Validation, erreurs et pagination
Une API REST qui renvoie directement des entités JPA en JSON expose le mot de passe haché de l'utilisateur, ses dates de création internes, les identifiants de ses relations. Techniquement, ça fonctionne. C'est aussi une fuite d'information.
Cette série en trois parties présente la manière de structurer une API REST avec Spring MVC et Spring Boot 3 : les patterns courants, les pièges à éviter et les choix qui simplifient la maintenance quand l'API grandit. Les exemples ont été compilés et exécutés avec Spring Boot 3.5 (Spring Framework 6.2, Spring Security 6.5), qui exige Java 17 au minimum.
Cette première partie pose les fondations : le contrôleur, les DTO, les annotations qui peuplent le conteneur Spring, la sérialisation JSON et Lombok. La deuxième partie traite de la validation, des erreurs et de la pagination, la troisième de la sécurité par JWT et des tests.
Le contrôleur REST : point d'entrée, pas logique métier
Un contrôleur Spring MVC a trois responsabilités : recevoir la requête HTTP, déléguer le traitement à un service et construire la réponse. Rien de plus. Dès qu'un contrôleur contient des conditions métier, des accès au repository ou de la transformation de données complexe, il devient difficile à tester et à faire évoluer.
Voici la structure pour un CRUD standard, rangée par fonctionnalité dans un package user (UserController, UserService, UserRepository, les DTO et les exceptions de la fonctionnalité) :
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserResponse>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
Passons en revue les choix faits dans ce code.
Le préfixe /api/v1 dans @RequestMapping("/api/v1/users") versionne l'API dès le départ. Sans versionnement, le jour où un changement incompatible devient nécessaire, le choix se résume à casser les clients existants ou à maintenir deux comportements dans le même endpoint avec des if. Les deux options sont mauvaises. Le versionnement par URL (/v1/, /v2/) est le plus simple à comprendre et à maintenir, et chaque version peut avoir son propre contrôleur si nécessaire.
Le ResponseEntity.created(location) sur le POST renvoie un code 201 avec un en-tête Location pointant vers la ressource créée. C'est ce que REST prescrit et ce que les clients attendent. Un POST qui renvoie 200 fonctionne, mais oblige le client à deviner l'URL de la ressource qu'il vient de créer.
Le ResponseEntity.noContent() sur le DELETE renvoie un 204 : la requête a réussi, il n'y a rien à renvoyer. Certaines API renvoient un 200 avec un message « supprimé avec succès ». Les deux se défendent, mais le 204 est sémantiquement plus précis.
Les noms suivent les conventions courantes de l'écosystème : XxxController, XxxService, XxxRepository, des entités au singulier, des DTO nommés CreateXxxRequest, UpdateXxxRequest et XxxResponse. Le service est ici une classe concrète. Une interface XxxService avec une implémentation XxxServiceImpl n'apporte quelque chose que lorsqu'il existe réellement plusieurs implémentations, ou quand le contrat doit être publié séparément.
Le findAll() renvoie ici une liste complète pour rester lisible. Sur une vraie table, il faut paginer : la deuxième partie y revient.
Les DTO : séparer ce que l'API expose de ce que la base contient
Renvoyer des entités JPA directement est une erreur classique, qui mérite qu'on s'y arrête. Une entité JPA reflète la structure de la table en base de données. Ce que l'API expose au monde extérieur n'a aucune raison d'être identique.
Les records Java (disponibles depuis Java 16, donc dans tout projet Spring Boot 3 qui exige Java 17) conviennent bien aux DTO : immuables, concis, sans code répétitif.
public record CreateUserRequest(
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 100)
String name,
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
String email,
@NotBlank(message = "Le mot de passe est obligatoire")
@Size(min = 15, max = 72, message = "Entre 15 et 72 caractères")
String password) {
}
public record UserResponse(Long id, String name, String email, LocalDateTime createdAt) {
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt());
}
}
CreateUserRequest décrit ce que le client envoie pour créer un utilisateur, UserResponse ce que l'API renvoie : ni mot de passe, ni champ interne. La méthode statique from(User) garde la conversion à un seul endroit. Quand les entités se multiplient, un générateur de mappers comme MapStruct évite d'écrire ce code à la main (voir la série sur l'application complète).
Le plafond max = 72 sur le mot de passe correspond à la limite de BCrypt, qui ne traite que les 72 premiers octets. Spring Security 6.5 (livré avec Spring Boot 3.5) rejette même un mot de passe plus long avec une IllegalArgumentException (« password cannot be more than 72 bytes ») ; les versions précédentes se contentaient de tronquer. Le minimum de 15 caractères et l'absence de règles de composition (majuscule, chiffre, symbole) suivent la recommandation NIST SP 800-63B-4 : au moins 15 caractères pour un mot de passe utilisé seul, et aucune règle de composition imposée.
Voici ce qui peut mal tourner sans DTO : une entité User avec un champ role (énumération ADMIN, USER, MODERATOR) utilisée directement comme corps de requête. Il suffit qu'un client modifie le JSON envoyé au PUT pour y inclure "role": "ADMIN" : Jackson désérialise le champ dans l'entité et le save() le persiste. C'est une attaque de type mass assignment. Un DTO de requête sans champ role la rend impossible. Dans l'autre sens, renvoyer l'entité expose aussi le rôle de chaque utilisateur dans les listes.
Les annotations Spring : comprendre qui fait quoi dans le conteneur
Spring Boot repose sur un conteneur d'injection de dépendances. Pour qu'il fonctionne, chaque classe doit être déclarée comme un bean, c'est-à-dire un objet géré par Spring. C'est le rôle des annotations stéréotypes, et chacune a une signification précise.
@Component est l'annotation générique. Elle dit à Spring : « cette classe est un bean, gère son cycle de vie ». Quand aucune annotation spécialisée ne s'applique (un utilitaire, un converter, un listener), @Component est le bon choix.
@Service est un @Component sémantique. Techniquement, elle fait exactement la même chose, mais en la lisant on sait immédiatement que la classe contient de la logique métier :
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Transactional
public InvoiceResponse create(CreateInvoiceRequest request) {
Invoice invoice = Invoice.builder()
.clientId(request.clientId())
.amount(request.amount())
.status(InvoiceStatus.PENDING)
.dueDate(request.dueDate())
.build();
return InvoiceResponse.from(invoiceRepository.save(invoice));
}
}
@Repository marque les classes d'accès aux données. Au-delà de la sémantique, elle a un effet technique : sur une classe, Spring traduit les exceptions de persistance (JPA, Hibernate, JDBC) en exceptions de sa hiérarchie DataAccessException, par exemple une DataIntegrityViolationException pour une contrainte d'unicité violée. Cette traduction est indépendante du moteur de persistance, ce qui facilite la gestion des erreurs dans le code applicatif.
Avec Spring Data JPA, on ne déclare pas de classe : l'interface suffit, et la traduction des exceptions est déjà appliquée aux repositories générés. L'annotation @Repository sur l'interface est facultative :
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
List<Invoice> findByClientIdOrderByCreatedAtDesc(Long clientId);
@Query("SELECT i FROM Invoice i WHERE i.status = :status AND i.dueDate < :date")
List<Invoice> findOverdueInvoices(@Param("status") InvoiceStatus status,
@Param("date") LocalDate date);
boolean existsByClientIdAndStatus(Long clientId, InvoiceStatus status);
}
Spring Data génère l'implémentation à partir du nom de la méthode. findByClientIdOrderByCreatedAtDesc produit un SELECT ... WHERE client_id = ? ORDER BY created_at DESC sans une ligne de SQL. Pour des requêtes plus complexes, @Query prend le relais avec du JPQL.
@Controller et @RestController marquent les points d'entrée HTTP. @Controller renvoie un nom de vue (Thymeleaf, JSP), @RestController sérialise directement l'objet retourné en JSON. @RestController est un raccourci pour @Controller + @ResponseBody sur chaque méthode.
@Configuration et @Bean vont ensemble. Une classe @Configuration est une classe de configuration explicite. Les méthodes annotées @Bean déclarent des beans que Spring ne peut pas détecter par scan, typiquement des objets de bibliothèques tierces :
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public RestClient restClient(RestClient.Builder builder) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(10));
return builder.requestFactory(factory).build();
}
}
BCryptPasswordEncoder vient du module spring-security-crypto, fourni par spring-boot-starter-security (la troisième partie l'utilise). Le constructeur new BCryptPasswordEncoder(12) fixe le facteur de coût à 12 au lieu de 10 : le hachage est volontairement lent.
RestClient (Spring Framework 6.1, donc Spring Boot 3.2 et suivants) est le client HTTP synchrone à privilégier pour du code nouveau. Son API fluide (restClient.get().uri(...).retrieve().body(Foo.class)) succède à RestTemplate : la documentation de Spring Framework 6.2 le présente comme l'API moderne pour l'accès HTTP synchrone, et RestTemplate reste supporté. Les timeouts ne doivent pas être oubliés : sans eux, un service distant lent bloque les threads du serveur. Les surcharges setConnectTimeout(Duration) et setReadTimeout(Duration) utilisées ici existent depuis Spring Framework 6.1. Depuis Spring Boot 3.4, les mêmes timeouts se règlent aussi par propriétés, qui s'appliquent à tous les clients auto-configurés (spring.http.client.connect-timeout et spring.http.client.read-timeout, documentées pour Spring Boot 3.5). La documentation de Spring Boot 4 les regroupe sous spring.http.clients.*.
La différence entre @Bean et @Component est fondamentale. @Component s'applique sur une classe que l'on contrôle, c'est-à-dire le code propre au projet. @Bean s'applique dans une méthode de factory pour créer un objet dont on ne contrôle pas la classe : PasswordEncoder ou RestClient viennent de bibliothèques tierces, on ne peut pas y ajouter @Component.
Jackson et les dates : ne pas déclarer son propre ObjectMapper
Déclarer un ObjectMapper en @Bean pour « corriger » le format des dates est inutile, et même contre-productif. Dans un projet Spring Boot, l'auto-configuration de Jackson enregistre déjà JavaTimeModule et désactive WRITE_DATES_AS_TIMESTAMPS : les LocalDateTime sont sérialisés en ISO-8601 ("2025-03-15T14:30:00") sans rien faire.
Trois comportements ont été vérifiés avec Spring Boot 3.5 :
- l'
ObjectMapperde Boot écrit"2025-03-15T14:30:00"; - un
new ObjectMapper()créé à la main échoue sur unLocalDateTimeavec uneInvalidDefinitionException, faute de module Java Time ; - un
ObjectMapperqui enregistreJavaTimeModulesans désactiverWRITE_DATES_AS_TIMESTAMPSproduit le tableau de nombres[2025,3,15,14,30], qui est le réglage par défaut de Jackson.
Déclarer son propre bean ObjectMapper remplace celui de Boot et fait perdre toutes ses personnalisations : propriétés spring.jackson.*, modules détectés automatiquement, réglages de désérialisation. Pour ajuster le comportement, on passe par les propriétés :
spring:
jackson:
default-property-inclusion: non_null # n'envoie pas les champs null
Pour une personnalisation plus fine, un bean Jackson2ObjectMapperBuilderCustomizer s'ajoute à la configuration de Boot sans la remplacer.
Lombok : réduire le code répétitif sans perdre en lisibilité
Java est verbeux par nature. Une entité JPA avec une poignée de champs nécessite un constructeur, des getters, des setters, equals(), hashCode(), toString() et un builder : des dizaines de lignes de code mécanique. Lombok les génère à la compilation. La dépendance se déclare avec <optional>true</optional>, et Spring Boot l'exclut du JAR exécutable.
Sur une entité JPA, il faut toutefois choisir les annotations avec soin. Voici une combinaison sûre :
@Entity
@Table(name = "invoices")
@Getter
@Setter
@Builder
@NoArgsConstructor // constructeur vide requis par JPA
@AllArgsConstructor // utilisé par @Builder
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long clientId;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private InvoiceStatus status;
@Column(nullable = false)
private LocalDate dueDate;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
Le @Builder permet de construire des objets de manière fluide et explicite, comme dans InvoiceService plus haut :
Invoice invoice = Invoice.builder()
.clientId(42L)
.amount(new BigDecimal("1500.00"))
.status(InvoiceStatus.PENDING)
.dueDate(LocalDate.now().plusDays(30))
.build();
Chaque champ est nommé explicitement. Pas de constructeur à huit paramètres positionnels où l'on confond l'ordre des arguments : c'est plus lisible, plus sûr, et l'IDE fournit l'autocomplétion sur les noms de champs.
Pourquoi pas @Data sur une entité
@Data est tentant, mais déconseillé sur les entités JPA. Il génère equals() et hashCode() basés sur tous les champs, id compris. Or, pour JPA, deux entités représentent le même enregistrement si leur id est identique. Avec un hashCode() calculé sur amount et status, une entité modifiée en mémoire change de hashCode() et se « perd » dans un Set ou une Map où elle avait été insérée. @Data génère aussi un toString() qui parcourt toutes les relations, ce qui déclenche des chargements paresseux involontaires, voire des boucles infinies sur les relations bidirectionnelles.
Une version plus sûre, ici avec une relation vers le client à la place du simple clientId :
@Entity
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... autres champs
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id")
@ToString.Exclude // évite les chargements et les boucles via toString()
private Client client;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Invoice other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Hibernate.getClass(this).hashCode();
}
}
@Getter et @Setter séparément plutôt que @Data : on garde les accesseurs sans le equals() et le hashCode() problématiques. Le @ToString.Exclude sur la relation évite que toString() charge le client ou boucle sur une relation bidirectionnelle.
L'égalité sur l'id avec un hashCode() constant par classe est un compromis courant. Hibernate remplace parfois une entité par un proxy, sous-classe générée dynamiquement : getClass() renverrait alors la classe du proxy, et deux représentations du même enregistrement auraient des hashCode() différents tout en étant equals(). Hibernate.getClass(this) renvoie la vraie classe de l'entité et règle ce point.
Pour les DTO, le choix est plus simple : les records sont immuables par nature et se passent de Lombok.
public record InvoiceResponse(Long id, BigDecimal amount, InvoiceStatus status, LocalDate dueDate) {
public static InvoiceResponse from(Invoice invoice) {
return new InvoiceResponse(invoice.getId(), invoice.getAmount(),
invoice.getStatus(), invoice.getDueDate());
}
}
À retenir de cette première partie
- Un contrôleur reçoit, délègue et construit la réponse : la logique métier vit dans le service.
ResponseEntityporte le statut (201 avecLocation, 204 sans corps) et les en-têtes.- Les DTO en
record(CreateXxxRequest,UpdateXxxRequest,XxxResponse) évitent la fuite de données et le mass assignment. @Beansert aux objets de bibliothèques tierces,@Componentet ses dérivés à son propre code.- L'
ObjectMapperde Spring Boot est déjà configuré : mieux vaut ne pas le remplacer. - Sur une entité,
@Getter,@Setteret@Buildersuffisent,@Dataest à éviter.
La partie suivante ajoute la validation des données entrantes, une gestion des erreurs unifiée et la pagination.
Série « Spring MVC et Spring Boot » : partie 1 sur 3.
Au sommaire de la série :
- Contrôleurs, DTO et conteneur Spring (cette partie)
- Validation, erreurs et pagination
- Sécurité JWT et tests
Partie suivante : Validation, erreurs et pagination