Série « Construire une application Spring Boot complète » : partie 1 sur 3.
Au sommaire de la série :
- Entités, DTO et services (cette partie)
- Sécurité JWT
- Contrôleurs, erreurs et documentation
Partie suivante : Sécurité JWT
Un premier projet Spring Boot ressemble souvent à ceci : toute la logique dans les contrôleurs, des entités JPA renvoyées directement dans les réponses JSON (mot de passe haché inclus), et une sécurité réduite à un @Secured posé sur deux endpoints. Le code fonctionne, mais il ne résiste ni à la première revue de code, ni au premier changement de spécifications.
Cette série en trois parties construit une application complète avec Spring Boot 3, Spring Security 6 et Java 17 : des entités JPA jusqu'à la documentation Swagger, en passant par la sécurité JWT et la gestion des erreurs. L'objectif est d'expliquer chaque choix d'architecture. Le déploiement lui-même n'est pas traité : la dernière partie liste ce qu'il resterait à faire pour une mise en production. Les exemples ont été compilés et exécutés avec Spring Boot 3.5 (Spring Framework 6.2, Spring Security 6.5, Hibernate 6.6).
Cette première partie couvre le socle : structure du projet, entités, DTO, mappers, repositories et services. La deuxième partie ajoute la sécurité JWT, la troisième les contrôleurs, la gestion des erreurs et la documentation. Les conventions de nommage sont celles de la série Spring MVC : XxxController, XxxService, XxxRepository, CreateXxxRequest et XxxResponse pour les DTO, XxxNotFoundException pour les exceptions.
Les dépendances Maven
Le pom.xml du projet réunit les starters web, validation, JPA et sécurité, jjwt pour les tokens, MapStruct pour les mappers, springdoc pour la documentation et Lombok. Les versions de Spring, Hibernate et Lombok sont gérées par le parent spring-boot-starter-parent ; jjwt, MapStruct et springdoc ne le sont pas et portent une version explicite.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>app</artifactId>
<version>0.0.1</version>
<properties>
<java.version>17</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>${mapstruct.version}</version></dependency>
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>2.8.17</version></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
Le choix de springdoc dépend de la génération de Spring Boot : la branche 2.8 de springdoc-openapi-starter-webmvc-ui est construite sur Spring Boot 3.5 (la 2.8.17 de l'exemple a pour parent Spring Boot 3.5.13), alors que la branche 3.x l'est sur Spring Boot 4. Les anciennes dépendances springfox ne conviennent pas à Spring Boot 3.
La structure du projet : une convention qui évite les débats
La première question sur tout nouveau projet Spring Boot est « où mettre quoi ». Les classes peuvent être triées par type technique (controller/, service/, repository/), par fonctionnalité, ou selon un mélange des deux sans logique apparente.
Cette série range le code par fonctionnalité, en packages minuscules :
src/main/java/com/example/app/
├── AppApplication.java
├── auth/ # AuthController, AuthService, LoginRequest, RegisterRequest,
│ # RefreshTokenRequest, AuthResponse, exceptions d'authentification
├── user/ # User, Role, RoleName, UserRepository, RoleRepository, UserMapper,
│ # UserService, UserController, UserResponse, UserNotFoundException
├── security/ # SecurityConfig, JwtService, JwtAuthenticationFilter,
│ # JwtAuthenticationEntryPoint, CustomUserDetailsService, UserSecurity
├── config/ # JpaConfig, OpenApiConfig
└── common/ # GlobalExceptionHandler
Le tri par fonctionnalité garde ensemble ce qui change ensemble : ajouter une fonctionnalité touche un package, pas quatre. Il permet aussi de limiter la visibilité (une classe sans modificateur public n'est visible que dans son package). Le tri par couche reste courant et se comprend vite ; l'essentiel est de n'en choisir qu'un par projet. Les suffixes (Controller, Service, Repository, Request, Response) portent alors l'information technique, ce qui évite les sous-packages dto/request et dto/response.
Les entités JPA : le miroir de la base, pas le modèle métier
Une entité JPA représente une table. Dès qu'on y ajoute de la logique de calcul, des méthodes de formatage ou des annotations de validation destinées à l'API, on mélange deux responsabilités qui évoluent à des rythmes différents. Les entités sont nommées au singulier (User, Role), même si la table est au pluriel.
Voici l'entité User avec audit automatique :
@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(name = "first_name", length = 100)
private String firstName;
@Column(name = "last_name", length = 100)
private String lastName;
@Column(name = "is_active", nullable = false)
@Builder.Default
private boolean active = true;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
@Builder.Default
private Set<Role> roles = new HashSet<>();
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return Hibernate.getClass(this).hashCode();
}
}
Reprenons les choix de cette entité. L'usage de @Getter et @Setter plutôt que @Data, ainsi que l'equals() sur l'id et le hashCode() constant par classe, sont expliqués dans la première partie de la série Spring MVC. Le point à retenir : equals() passe par other.getId() et non par other.id, car l'accès direct au champ d'un proxy Hibernate renvoie null. Quand l'entité possède une clé métier naturelle et immuable, la documentation Hibernate la privilégie pour equals() et hashCode().
@Builder.Default sur active et roles est nécessaire parce que @Builder ignore les valeurs par défaut des champs. Sans cette annotation, User.builder().build() produirait un utilisateur avec active = false et roles = null au lieu de true et new HashSet<>().
FetchType.LAZY sur les rôles. C'est d'ailleurs la valeur par défaut des @ManyToMany. On pourrait croire qu'il faut du EAGER parce que Spring Security a besoin des rôles à chaque requête authentifiée. Ce n'est pas nécessaire : loadUserByUsername() est annoté @Transactional (voir la partie suivante), les rôles y sont lus pendant que la session est ouverte, puis copiés dans les autorités. Un EAGER s'appliquerait en plus à toutes les autres requêtes qui chargent un User, y compris celles qui n'ont pas besoin des rôles. Pour charger l'utilisateur et ses rôles en une seule requête, on place un @EntityGraph sur la méthode du repository.
@EntityListeners(AuditingEntityListener.class) avec @CreatedDate et @LastModifiedDate automatise les timestamps d'audit. Plus besoin de user.setCreatedAt(LocalDateTime.now()) dans chaque service. Il faut activer l'auditing dans une classe de configuration :
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
L'entité Role s'appuie sur une énumération RoleName. Dans la série Spring MVC, Role était une simple énumération portée par User ; ici, l'utilisateur peut cumuler plusieurs rôles, donc Role devient une entité et l'énumération se renomme RoleName.
public enum RoleName {
USER, MODERATOR, ADMIN
}
@Entity
@Table(name = "roles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, unique = true, length = 20)
private RoleName name;
}
EnumType.STRING plutôt que EnumType.ORDINAL : avec ORDINAL, les valeurs sont stockées comme des entiers correspondant à leur position dans l'enum (0, 1, 2). Il suffit d'insérer une valeur au milieu de l'enum, ou d'en réordonner les constantes, pour que toutes les lignes existantes pointent vers un autre rôle, sans aucune erreur. Des comptes administrateurs pourraient ainsi devenir de simples utilisateurs après l'ajout d'un rôle intermédiaire. Avec STRING, le nom est stocké tel quel. Le revers est qu'on ne peut plus renommer une constante sans migrer les données, ce qui est au moins un problème visible et facile à traiter.
Les noms de l'énumération n'ont pas le préfixe ROLE_ : c'est CustomUserDetailsService qui le fait ajouter à la construction du UserDetails, ce qui permet d'écrire hasRole("ADMIN") (voir la partie suivante).
Les DTO : le contrat de l'API, jamais le reflet de la base
Le piège classique : renvoyer l'entité JPA directement en réponse HTTP. Le mot de passe (même haché), les timestamps internes, les clés étrangères techniques, tout part dans le JSON. Dans l'autre sens, un client peut envoyer un JSON avec "role": "ADMIN" et Jackson le désérialise dans l'entité (le mass assignment décrit dans la série Spring MVC). Les DTO créent deux frontières : ce que le client peut envoyer, et ce que l'API choisit de renvoyer.
Le DTO d'inscription porte toute la validation :
public record RegisterRequest(
@NotBlank(message = "Le nom d'utilisateur est obligatoire")
@Size(min = 3, max = 50, message = "Entre 3 et 50 caractères")
@Pattern(
regexp = "^[A-Za-z0-9._-]+$",
message = "Lettres, chiffres, point, tiret et underscore uniquement")
String username,
@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,
@Size(max = 100) String firstName,
@Size(max = 100) String lastName) {
}
La borne max = 72 tient à BCrypt, qui ne traite que les 72 premiers octets du mot de passe ; Spring Security 6.5 rejette d'ailleurs un mot de passe plus long avec une IllegalArgumentException. Le minimum de 15 caractères et l'absence de règle de composition suivent la recommandation NIST SP 800-63B-4 (15 caractères au moins pour un mot de passe utilisé seul, aucune règle de composition imposée), plutôt que le classique « une majuscule, un chiffre, un symbole ». Le @Pattern sur username exclut notamment @ : cette règle a une raison de sécurité, expliquée avec le CustomUserDetailsService dans la partie suivante.
Les records Java (Java 16+) conviennent bien aux DTO. Ils sont immuables par nature, sans setter ni modification accidentelle après construction.
Le DTO de connexion est minimal. Le champ accepte indifféremment le nom d'utilisateur ou l'e-mail :
public record LoginRequest(
@NotBlank(message = "L'identifiant est obligatoire")
String username,
@NotBlank(message = "Le mot de passe est obligatoire")
String password) {
}
Le DTO de réponse choisit exactement ce qui sort. Pas de mot de passe, pas de relations JPA, pas de champs internes :
public record UserResponse(
Long id,
String username,
String email,
String firstName,
String lastName,
boolean active,
Set<String> roles,
LocalDateTime createdAt) {
}
Pour les réponses d'authentification, un DTO dédié regroupe les tokens, la durée de vie de l'access token et les informations de l'utilisateur. C'est la version étendue de l'AuthResponse de la série Spring MVC, qui ne portait qu'un access token :
public record AuthResponse(
String accessToken,
String refreshToken,
String tokenType,
long expiresIn,
UserResponse user) {
public AuthResponse(String accessToken, String refreshToken, long expiresIn, UserResponse user) {
this(accessToken, refreshToken, "Bearer", expiresIn, user);
}
}
Les succès sont renvoyés tels quels, sans enveloppe success / data, et les erreurs suivent le format ProblemDetail (RFC 9457) de la série Spring MVC. Une enveloppe qui double les codes HTTP oblige les clients à tester deux niveaux de succès, et son champ success doit alors rester strictement cohérent avec le statut. La troisième partie détaille le format d'erreur retenu.
Les mappers : convertir sans polluer le service
La conversion entité vers DTO peut se faire à la main dans le service. Avec 3 champs, c'est rapide. Avec 15 champs et des relations imbriquées, le code devient mécanique et encombre la logique métier.
MapStruct génère le code de mapping à la compilation : pas de réflexion au runtime, pas d'impact sur les performances.
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "password", ignore = true) // à hacher avant d'être posé
@Mapping(target = "roles", ignore = true)
@Mapping(target = "active", ignore = true) // la valeur par défaut du builder s'applique
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
User toEntity(RegisterRequest request);
@Mapping(target = "roles", expression = "java(mapRoles(user.getRoles()))")
UserResponse toResponse(User user);
default Set<String> mapRoles(Set<Role> roles) {
if (roles == null) {
return Set.of();
}
return roles.stream()
.map(role -> role.getName().name())
.collect(Collectors.toSet());
}
}
Les @Mapping(target = ..., ignore = true) disent explicitement à MapStruct de ne pas toucher certains champs. Le mot de passe du DTO ne doit pas être copié tel quel dans l'entité : il doit d'abord être haché, et le ignore = true documente cette intention. Pour active, le ignore = true laisse jouer la valeur par défaut du builder Lombok : un utilisateur inscrit est actif (vérifié par le test d'intégration de la troisième partie, qui renvoie "active": true).
MapStruct et Lombok ensemble. Les deux sont des annotation processors, et MapStruct doit voir les getters, setters et builders que Lombok génère. Avec Maven, le pom.xml ci-dessus déclare Lombok avant MapStruct dans annotationProcessorPaths, avec lombok-mapstruct-binding entre les deux. La FAQ de MapStruct demande ce module à partir de Lombok 1.18.16. Sur cet exemple (Lombok 1.18.46 fourni par Spring Boot 3.5.16, MapStruct 1.6.3), la compilation produit un code identique avec ou sans lui ; il reste prescrit par la documentation de MapStruct, et la série le conserve.
Les repositories : laisser Spring Data travailler
Spring Data JPA génère les requêtes à partir des noms de méthodes. C'est pratique au quotidien, à condition de ne pas en abuser.
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "roles")
Optional<User> findByUsername(String username);
@EntityGraph(attributePaths = "roles")
Optional<User> findByUsernameOrEmail(String username, String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
@Query("SELECT u FROM User u JOIN u.roles r WHERE r.name = :roleName")
List<User> findByRoleName(@Param("roleName") RoleName roleName);
}
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(RoleName name);
}
Les noms de méthodes Spring Data ont une limite de lisibilité. findByUsername est clair. findByActiveTrueAndRolesNameOrderByCreatedAtDesc est valide mais illisible. Au-delà de deux critères, @Query avec du JPQL est plus explicite et plus facile à maintenir.
Le @EntityGraph(attributePaths = "roles") charge l'utilisateur et ses rôles en une seule requête avec jointure, tout en gardant la relation en LAZY partout ailleurs. Une interface qui étend JpaRepository n'a pas besoin de @Repository.
Les services : la logique métier et rien d'autre
Le débat « interface + implémentation » pour les services revient régulièrement. Avec une seule implémentation, une interface AuthService plus une classe AuthServiceImpl ajoutent deux fichiers et un sous-package impl/ sans rien apporter de plus qu'une classe concrète : Spring injecte directement la classe, et Mockito sait la simuler. Cette série utilise donc des services concrets, comme la série Spring MVC. L'interface se justifie quand il existe réellement plusieurs implémentations, ou quand un module doit exposer un contrat sans exposer son code.
@Service
@Transactional
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthService(UserRepository userRepository,
RoleRepository roleRepository,
UserMapper userMapper,
PasswordEncoder passwordEncoder,
AuthenticationManager authenticationManager,
JwtService jwtService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
public AuthResponse login(LoginRequest request) {
// Lève BadCredentialsException (ou DisabledException) en cas d'échec.
// Le champ accepte le username ou l'email : CustomUserDetailsService fait la résolution.
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
// On repart du username canonique du principal, pas de la saisie du client,
// qui peut être un email.
UserDetails principal = (UserDetails) authentication.getPrincipal();
User user = userRepository.findByUsername(principal.getUsername()).orElseThrow();
log.info("Connexion réussie : userId={}", user.getId());
return buildAuthResponse(user);
}
public UserResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new UsernameAlreadyUsedException();
}
if (userRepository.existsByEmail(request.email())) {
throw new EmailAlreadyUsedException();
}
User user = userMapper.toEntity(request);
user.setPassword(passwordEncoder.encode(request.password()));
Role userRole = roleRepository.findByName(RoleName.USER)
.orElseThrow(() -> new IllegalStateException(
"Rôle USER absent de la base : les rôles doivent être créés au démarrage"));
user.setRoles(new HashSet<>(Set.of(userRole)));
User saved = userRepository.save(user);
log.info("Utilisateur créé : userId={}", saved.getId());
return userMapper.toResponse(saved);
}
public AuthResponse refresh(String refreshToken) {
if (!jwtService.isRefreshTokenValid(refreshToken)) {
throw new InvalidRefreshTokenException();
}
String username = jwtService.extractUsername(refreshToken);
User user = userRepository.findByUsername(username)
.filter(User::isActive)
.orElseThrow(InvalidRefreshTokenException::new);
// Rotation : un refresh token ne sert qu'une fois
jwtService.revoke(refreshToken);
return buildAuthResponse(user);
}
public void logout(String accessToken, String refreshToken) {
jwtService.revoke(accessToken);
if (refreshToken != null) {
jwtService.revoke(refreshToken);
}
log.info("Déconnexion effectuée");
}
private AuthResponse buildAuthResponse(User user) {
return new AuthResponse(
jwtService.generateAccessToken(user.getUsername()),
jwtService.generateRefreshToken(user.getUsername()),
jwtService.getAccessTokenExpirationSeconds(),
userMapper.toResponse(user));
}
}
Détail de ce service.
Le @Transactional au niveau de la classe. Toutes les méthodes publiques s'exécutent dans une transaction. Si une exception non contrôlée survient après le save() dans register(), la transaction est annulée : pas d'utilisateur à moitié créé en base. Pour les méthodes purement en lecture, @Transactional(readOnly = true) est préférable : Spring le transmet au fournisseur JPA et à la connexion JDBC comme une indication qu'aucune écriture n'est attendue.
La vérification d'unicité avant l'insertion. On pourrait se contenter de la DataIntegrityViolationException que la contrainte UNIQUE lèverait, mais le message de la base est cryptique et dépend du moteur SQL. Vérifier explicitement permet de renvoyer un message clair avec un 409 Conflict. Cette vérification ne remplace pas la contrainte : deux inscriptions simultanées peuvent passer le test en même temps, et c'est la base qui tranche. Le handler global traduit alors la DataIntegrityViolationException en 409 également. Ces messages révèlent forcément qu'un nom d'utilisateur ou un e-mail est déjà pris : sans eux, l'utilisateur ne saurait pas pourquoi son inscription échoue. Seule la connexion renvoie un message générique.
Le login s'appuie sur le principal authentifié. authenticationManager.authenticate() accepte un username ou un e-mail, car le UserDetailsService utilise findByUsernameOrEmail. Recharger l'utilisateur avec findByUsername(request.username()) échouerait donc pour quelqu'un qui se connecte avec son e-mail. On relit l'utilisateur à partir du username du principal.
passwordEncoder.encode() est appelé dans le service, pas dans le mapper. Le hachage du mot de passe est une opération métier. Le mapper ne fait que de la conversion structurelle.
Le rôle USER doit exister en base. S'il manque, ce n'est pas le client qui a fait une erreur : c'est le serveur qui est mal initialisé. Le service lève donc une IllegalStateException, que le handler global transforme en 500, et non une exception de type « introuvable » qui répondrait 404. Les rôles se créent au déploiement, par une migration Flyway (INSERT INTO roles (name) VALUES ('USER'), ('MODERATOR'), ('ADMIN');), ou au démarrage par un CommandLineRunner qui n'insère que les rôles manquants.
Le refresh token est ici une simple chaîne validée par JwtService, dont le fonctionnement est détaillé dans la partie suivante. refresh() vérifie que le compte est toujours actif, révoque l'ancien refresh token, puis en émet un nouveau (rotation) : un compte désactivé ne peut plus se renouveler. logout() révoque l'access token et, s'il est fourni, le refresh token.
À retenir de cette première partie
- Un seul tri du code par projet : ici par fonctionnalité, avec des noms en minuscules pour les packages.
- Les entités restent le miroir de la base ; les DTO (
RegisterRequest,UserResponse,AuthResponse) forment le contrat de l'API. - MapStruct génère les mappers à la compilation, Lombok se déclare avant lui dans les annotation processors.
- Un service concret suffit tant qu'il n'existe qu'une implémentation.
- Le hachage du mot de passe, l'unicité et la rotation du refresh token vivent dans le service, pas dans le mapper ni dans le contrôleur.
La partie suivante construit tout ce que ce service appelle : JwtService, le filtre JWT, CustomUserDetailsService et SecurityConfig.
Série « Construire une application Spring Boot complète » : partie 1 sur 3.
Au sommaire de la série :
- Entités, DTO et services (cette partie)
- Sécurité JWT
- Contrôleurs, erreurs et documentation
Partie suivante : Sécurité JWT