CRUD complet avec Spring Boot : Guide pratique de A à Z

13 min readPar SpringCraft

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

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 String
  • updatable = false : La valeur ne peut pas être modifiée après création (pour createdAt)

@PrePersist et @PreUpdate

Ces méthodes sont appelées automatiquement par JPA :

  • @PrePersist : Avant l'insertion en base (pour initialiser createdAt)
  • @PreUpdate : Avant la mise à jour (pour mettre à jour updatedAt)

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 à jour
  • findById(Long id) : Récupère par ID
  • findAll() : Récupère tous les utilisateurs
  • deleteById(Long id) : Supprime par ID
  • existsById(Long id) : Vérifie si existe

Méthodes personnalisées :

  • findByEmail(String email) : Spring génère automatiquement SELECT * FROM users WHERE email = ?
  • existsByEmail(String email) : Spring génère SELECT 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 HTTPURLActionCode succès
POST/api/usersCréer un utilisateur201 Created
GET/api/usersRécupérer tous les utilisateurs200 OK
GET/api/users/{id}Récupérer un utilisateur200 OK
PUT/api/users/{id}Mettre à jour un utilisateur200 OK
DELETE/api/users/{id}Supprimer un utilisateur204 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-drop

Démarrer l'application

mvn spring-boot:run

Tester 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/users

Récupérer un utilisateur par ID

curl http://localhost:8080/api/users/1

Mettre à 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/1

Ré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

  1. Comment structurer un projet Spring Boot - La base de tout
  2. Tests dans Spring Boot : Guide complet - Testez votre API
  3. Configuration multi-environnements - Déployez en prod
  4. Gestion des exceptions dans Spring Boot - On l'a mis en pratique !
  5. Spring Boot en production : Checklist - Préparez le déploiement

🎯 Points clés à retenir

  1. Structure en couches : Entity, DTO, Repository, Service, Controller
  2. DTOs séparés : CreateRequest, UpdateRequest, Response
  3. Validation avec @Valid : Automatique et propre
  4. Gestion d'erreur centralisée : @ControllerAdvice
  5. Transactions : @Transactional pour la cohérence
  6. Logs : Pour débugger et auditer

Vous avez maintenant un template réutilisable pour créer n'importe quelle API REST ! 🚀

Bon développement ! 💻