CRUD complet avec Spring Boot : Guide pratique de A à Z
Tu as appris la théorie sur Spring Boot : structure, tests, configuration, exceptions. Maintenant, passons à la pratique !
Dans cet article, nous allons construire de zéro une API REST complète pour gérer des utilisateurs. Cette API permettra de :
- ✅ Create : Créer un utilisateur
- ✅ Read : Récupérer un ou plusieurs utilisateurs
- ✅ Update : Mettre à jour un utilisateur
- ✅ Delete : Supprimer un utilisateur
Nous allons mettre en pratique tout ce que vous avez appris : structure en couches, validation, gestion d'erreur, tests, etc.
Contexte technique
Versions utilisées :
- Spring Boot 3.2+
- Java 17+
- H2 Database pour le développement
- spring-boot-starter-validation pour la validation
- Lombok pour réduire le boilerplate
Sommaire
- Initialiser le projet
- Créer l'entité User
- Créer les DTOs
- Créer le Repository
- Créer le Mapper
- Créer le Service
- Créer le Controller
- Gérer les exceptions
- Tester l'API
- Améliorations possibles
- Pour aller plus loin
Initialiser le projet
1. Créer le projet avec Spring Initializr
Allez sur start.spring.io et configurez :
- Project : Maven
- Language : Java
- Spring Boot : 3.2.x (dernière version stable)
- Java : 17 ou 21
- Dependencies :
- Spring Web
- Spring Data JPA
- H2 Database (pour le développement)
- Validation
- Lombok (optionnel mais très pratique)
2. Structure du projet
Créez la structure suivante :
src/main/java/com/springcraft/userapi/
├─ controller/
│ └─ UserController.java
├─ service/
│ └─ UserService.java
├─ repository/
│ └─ UserRepository.java
├─ model/
│ ├─ entity/
│ │ └─ User.java
│ └─ dto/
│ ├─ UserDTO.java
│ ├─ CreateUserRequest.java
│ └─ UpdateUserRequest.java
├─ mapper/
│ └─ UserMapper.java
├─ exception/
│ ├─ ResourceNotFoundException.java
│ ├─ ValidationException.java
│ └─ GlobalExceptionHandler.java
└─ UserApiApplication.java
Créer l'entité User
L'entité représente la table en base de données.
package com.springcraft.userapi.model.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Data // Lombok génère getters, setters, equals, hashCode, toString
@NoArgsConstructor // Constructeur vide (requis par JPA)
@AllArgsConstructor // Constructeur avec tous les champs
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String name;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist // Appelé avant l'insertion
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate // Appelé avant la mise à jour
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}Décortiquons l'entité
@Entity
Dit à JPA : "Cette classe représente une table en base de données".
@Table(name = "users")
Spécifie le nom de la table. Sans ça, JPA utiliserait le nom de la classe (User).
Pourquoi users et pas user ?
user est souvent un mot réservé dans les bases de données. Pour éviter les conflits, on utilise le pluriel.
@Id et @GeneratedValue
@Id: Clé primaire@GeneratedValue(strategy = GenerationType.IDENTITY): La base génère automatiquement l'ID (auto-increment)
@Column
Configure les propriétés de la colonne :
nullable = false: Le champ est obligatoire (NOT NULL en SQL)unique = true: Le champ doit être unique (pour l'email)length = 100: Taille maximale de la Stringupdatable = false: La valeur ne peut pas être modifiée après création (pourcreatedAt)
@PrePersist et @PreUpdate
Ces méthodes sont appelées automatiquement par JPA :
@PrePersist: Avant l'insertion en base (pour initialisercreatedAt)@PreUpdate: Avant la mise à jour (pour mettre à jourupdatedAt)
Ainsi, vous n'avez pas besoin de gérer manuellement ces dates dans votre code.
Créer les DTOs
Les DTOs (Data Transfer Objects) sont les objets échangés avec le client. On ne doit jamais exposer les entités directement.
UserDTO (pour la lecture)
package com.springcraft.userapi.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String email;
private String name;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}Pourquoi un DTO séparé ?
- Vous contrôlez exactement ce qui est exposé (par exemple, on ne veut pas exposer un mot de passe)
- Vous pouvez changer la structure de la base sans casser l'API
- Vous pouvez ajouter des champs calculés qui n'existent pas en base
CreateUserRequest (pour la création)
package com.springcraft.userapi.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserRequest {
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
private String email;
@NotBlank(message = "Le nom est obligatoire")
@Size(min = 2, max = 100, message = "Le nom doit contenir entre 2 et 100 caractères")
private String name;
}Pourquoi séparer Create et Update ?
- Les champs requis peuvent être différents
- Pour la création, on ne spécifie pas l'ID (il est généré)
- Pour la mise à jour, certains champs peuvent être optionnels
UpdateUserRequest (pour la mise à jour)
package com.springcraft.userapi.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateUserRequest {
@Email(message = "Format d'email invalide")
private String email; // Optionnel
@Size(min = 2, max = 100, message = "Le nom doit contenir entre 2 et 100 caractères")
private String name; // Optionnel
}Notez : Ici, les champs sont optionnels (pas de @NotBlank). On ne met à jour que les champs fournis.
Créer le Repository
Le repository gère l'accès à la base de données. Avec Spring Data JPA, c'est ultra-simple.
package com.springcraft.userapi.repository;
import com.springcraft.userapi.model.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data génère automatiquement l'implémentation
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}Magie Spring Data : Vous définissez juste l'interface. Spring génère automatiquement les requêtes SQL !
Méthodes héritées de JpaRepository :
save(User user): Crée ou met à jourfindById(Long id): Récupère par IDfindAll(): Récupère tous les utilisateursdeleteById(Long id): Supprime par IDexistsById(Long id): Vérifie si existe
Méthodes personnalisées :
findByEmail(String email): Spring génère automatiquementSELECT * FROM users WHERE email = ?existsByEmail(String email): Spring génèreSELECT COUNT(*) > 0 FROM users WHERE email = ?
Créer le Mapper
Le mapper convertit Entity ↔ DTO.
package com.springcraft.userapi.mapper;
import com.springcraft.userapi.model.dto.CreateUserRequest;
import com.springcraft.userapi.model.dto.UserDTO;
import com.springcraft.userapi.model.entity.User;
import org.springframework.stereotype.Component;
@Component
public class UserMapper {
public UserDTO toDTO(User user) {
if (user == null) {
return null;
}
return new UserDTO(
user.getId(),
user.getEmail(),
user.getName(),
user.getCreatedAt(),
user.getUpdatedAt()
);
}
public User toEntity(CreateUserRequest request) {
if (request == null) {
return null;
}
User user = new User();
user.setEmail(request.getEmail());
user.setName(request.getName());
return user;
}
}Pourquoi un Mapper ?
- Séparation des responsabilités : La logique de conversion est centralisée
- Testable : Vous pouvez tester le mapper indépendamment
- Réutilisable : Vous utilisez le même mapper partout
Alternative : Vous pouvez utiliser MapStruct, une librairie qui génère automatiquement les mappers. Mais pour débuter, un mapper manuel est plus simple.
Créer le Service
Le service contient toute la logique métier.
package com.springcraft.userapi.service;
import com.springcraft.userapi.exception.ResourceNotFoundException;
import com.springcraft.userapi.exception.ValidationException;
import com.springcraft.userapi.mapper.UserMapper;
import com.springcraft.userapi.model.dto.CreateUserRequest;
import com.springcraft.userapi.model.dto.UpdateUserRequest;
import com.springcraft.userapi.model.dto.UserDTO;
import com.springcraft.userapi.model.entity.User;
import com.springcraft.userapi.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor // Lombok génère le constructeur avec tous les champs final
@Slf4j // Lombok génère un logger
@Transactional(readOnly = true) // Par défaut, toutes les méthodes sont en lecture seule
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
// CREATE
@Transactional // Méthode en écriture (override le readOnly)
public UserDTO createUser(CreateUserRequest request) {
log.info("Création d'un utilisateur avec l'email : {}", request.getEmail());
// Validation métier : l'email doit être unique
if (userRepository.existsByEmail(request.getEmail())) {
throw new ValidationException("Un utilisateur existe déjà avec cet email : " + request.getEmail());
}
User user = userMapper.toEntity(request);
User savedUser = userRepository.save(user);
log.info("Utilisateur créé avec succès, ID : {}", savedUser.getId());
return userMapper.toDTO(savedUser);
}
// READ - Récupérer tous les utilisateurs
public List<UserDTO> getAllUsers() {
log.info("Récupération de tous les utilisateurs");
return userRepository.findAll()
.stream()
.map(userMapper::toDTO)
.collect(Collectors.toList());
}
// READ - Récupérer un utilisateur par son ID
public UserDTO getUserById(Long id) {
log.info("Récupération de l'utilisateur avec l'ID : {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id));
return userMapper.toDTO(user);
}
// UPDATE
@Transactional
public UserDTO updateUser(Long id, UpdateUserRequest request) {
log.info("Mise à jour de l'utilisateur avec l'ID : {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id));
// Mettre à jour uniquement les champs fournis
if (request.getEmail() != null) {
// Vérifier que l'email n'est pas déjà utilisé par un autre utilisateur
if (userRepository.existsByEmail(request.getEmail()) &&
!user.getEmail().equals(request.getEmail())) {
throw new ValidationException("Un utilisateur existe déjà avec cet email : " + request.getEmail());
}
user.setEmail(request.getEmail());
}
if (request.getName() != null) {
user.setName(request.getName());
}
User updatedUser = userRepository.save(user);
log.info("Utilisateur mis à jour avec succès");
return userMapper.toDTO(updatedUser);
}
// DELETE
@Transactional
public void deleteUser(Long id) {
log.info("Suppression de l'utilisateur avec l'ID : {}", id);
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id);
}
userRepository.deleteById(id);
log.info("Utilisateur supprimé avec succès");
}
}Décortiquons le Service
@RequiredArgsConstructor
Lombok génère automatiquement un constructeur avec tous les champs final. C'est équivalent à :
public UserService(UserRepository userRepository, UserMapper userMapper) {
this.userRepository = userRepository;
this.userMapper = userMapper;
}Pourquoi utiliser l'injection par constructeur ?
- C'est la méthode recommandée par Spring
- Les dépendances sont
final(immutables) - Facile à tester (pas besoin de Spring pour instancier)
@Transactional
Gère automatiquement les transactions :
@Transactional(readOnly = true): Au niveau de la classe, toutes les méthodes sont en lecture seule@Transactional: Au niveau de la méthode, override le readOnly pour les méthodes en écriture
Pourquoi c'est important ?
- Si une méthode échoue, la transaction est rollback automatiquement
- Les modifications en base sont atomiques (tout ou rien)
Logs
On log les actions importantes pour faciliter le debugging et l'audit.
Créer le Controller
Le controller expose l'API REST.
package com.springcraft.userapi.controller;
import com.springcraft.userapi.model.dto.CreateUserRequest;
import com.springcraft.userapi.model.dto.UpdateUserRequest;
import com.springcraft.userapi.model.dto.UserDTO;
import com.springcraft.userapi.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// CREATE - POST /api/users
@PostMapping
public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDTO createdUser = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
// READ - GET /api/users
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers() {
List<UserDTO> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
// READ - GET /api/users/{id}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
UserDTO user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
// UPDATE - PUT /api/users/{id}
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserDTO updatedUser = userService.updateUser(id, request);
return ResponseEntity.ok(updatedUser);
}
// DELETE - DELETE /api/users/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}Les endpoints
| Méthode HTTP | URL | Action | Code succès |
|---|---|---|---|
| POST | /api/users | Créer un utilisateur | 201 Created |
| GET | /api/users | Récupérer tous les utilisateurs | 200 OK |
| GET | /api/users/{id} | Récupérer un utilisateur | 200 OK |
| PUT | /api/users/{id} | Mettre à jour un utilisateur | 200 OK |
| DELETE | /api/users/{id} | Supprimer un utilisateur | 204 No Content |
Annotations
@RestController
Combinaison de @Controller + @ResponseBody. Toutes les méthodes renvoient du JSON automatiquement.
@RequestMapping("/api/users")
Toutes les routes de ce controller commencent par /api/users.
@PostMapping, @GetMapping, etc.
Raccourcis pour @RequestMapping(method = RequestMethod.POST), etc.
@Valid
Active la validation automatique. Si invalide, Spring lance une MethodArgumentNotValidException.
@PathVariable
Récupère une variable depuis l'URL. Exemple : /api/users/123 donne id = 123
@RequestBody
Récupère le corps de la requête et le convertit automatiquement en objet Java.
Gérer les exceptions
Créons les exceptions personnalisées et le gestionnaire global.
ResourceNotFoundException
package com.springcraft.userapi.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}ValidationException
package com.springcraft.userapi.exception;
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}GlobalExceptionHandler
package com.springcraft.userapi.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.error("Ressource non trouvée : {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
log.warn("Erreur de validation : {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("errors", errors);
response.put("timestamp", LocalDateTime.now());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Erreur inattendue", ex);
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Une erreur interne est survenue",
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
record ErrorResponse(int status, String message, LocalDateTime timestamp) {}Tester l'API
Configuration H2 (base en mémoire)
Dans src/main/resources/application.yml :
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
path: /h2-console
jpa:
show-sql: true
hibernate:
ddl-auto: create-dropDémarrer l'application
mvn spring-boot:runTester avec curl ou Postman
Créer un utilisateur
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"name": "John Doe"
}'Réponse (201 Created) :
{
"id": 1,
"email": "john@example.com",
"name": "John Doe",
"createdAt": "2026-01-30T10:30:00",
"updatedAt": "2026-01-30T10:30:00"
}Récupérer tous les utilisateurs
curl http://localhost:8080/api/usersRécupérer un utilisateur par ID
curl http://localhost:8080/api/users/1Mettre à jour un utilisateur
curl -X PUT http://localhost:8080/api/users/1 \
-H "Content-Type: application/json" \
-d '{
"name": "John Updated"
}'Supprimer un utilisateur
curl -X DELETE http://localhost:8080/api/users/1Réponse : 204 No Content
Améliorations possibles
Ce CRUD est fonctionnel, mais voici quelques améliorations :
1. Pagination
Pour ne pas renvoyer tous les utilisateurs d'un coup :
@GetMapping
public ResponseEntity<Page<UserDTO>> getAllUsers(Pageable pageable) {
Page<UserDTO> users = userService.getAllUsers(pageable);
return ResponseEntity.ok(users);
}2. Recherche et filtres
@GetMapping("/search")
public ResponseEntity<List<UserDTO>> searchUsers(@RequestParam String name) {
List<UserDTO> users = userService.searchByName(name);
return ResponseEntity.ok(users);
}3. Documentation OpenAPI/Swagger
Ajoutez la dépendance springdoc-openapi-starter-webmvc-ui et accédez à /swagger-ui.html pour voir la doc interactive.
4. Sécurité
Ajoutez Spring Security pour protéger vos endpoints.
5. Caching
Utilisez @Cacheable pour mettre en cache les résultats.
Pour aller plus loin
Félicitations ! Vous avez créé votre première API REST complète avec Spring Boot. 🎉
📚 Série d'articles complémentaires
- Comment structurer un projet Spring Boot - La base de tout
- Tests dans Spring Boot : Guide complet - Testez votre API
- Configuration multi-environnements - Déployez en prod
- Gestion des exceptions dans Spring Boot - On l'a mis en pratique !
- Spring Boot en production : Checklist - Préparez le déploiement
🎯 Points clés à retenir
- Structure en couches : Entity, DTO, Repository, Service, Controller
- DTOs séparés : CreateRequest, UpdateRequest, Response
- Validation avec @Valid : Automatique et propre
- Gestion d'erreur centralisée : @ControllerAdvice
- Transactions : @Transactional pour la cohérence
- Logs : Pour débugger et auditer
Vous avez maintenant un template réutilisable pour créer n'importe quelle API REST ! 🚀
Bon développement ! 💻