Spring Boot : créer une API REST propre en Java
De la structure du projet à la gestion des erreurs : guide pratique pour construire une API REST robuste avec Spring Boot, validation, couches service et gestion des exceptions.
Spring Boot simplifie considérablement le démarrage d’une API REST en Java. Mais sans structure claire, un projet grossit vite et devient difficile à maintenir. Ce guide couvre les fondamentaux d’une API bien construite : séparation des couches, validation, gestion des erreurs et tests.
Structure du projet
Une API Spring Boot propre sépare les responsabilités en couches :
src/main/java/com/monapp/
controller/ ← points d'entrée HTTP
service/ ← logique métier
repository/ ← accès données (JPA)
model/ ← entités JPA
dto/ ← objets de transfert (Request/Response)
exception/ ← exceptions métier
config/ ← configuration Spring
Les DTOs sont essentiels : ne jamais exposer directement les entités JPA dans les réponses API.
Le Controller
@RestController
@RequestMapping("/api/v1/projets")
@Validated
public class ProjetController {
private final ProjetService projetService;
public ProjetController(ProjetService projetService) {
this.projetService = projetService;
}
@GetMapping
public ResponseEntity<List<ProjetResponse>> findAll() {
return ResponseEntity.ok(projetService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<ProjetResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(projetService.findById(id));
}
@PostMapping
public ResponseEntity<ProjetResponse> create(
@Valid @RequestBody ProjetRequest request) {
ProjetResponse created = projetService.create(request);
URI location = URI.create("/api/v1/projets/" + created.id());
return ResponseEntity.created(location).body(created);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
projetService.delete(id);
return ResponseEntity.noContent().build();
}
}
Le controller ne contient aucune logique métier. Il délègue tout au service et gère uniquement les codes HTTP de retour.
La couche Service
@Service
@Transactional
public class ProjetService {
private final ProjetRepository repository;
private final ProjetMapper mapper;
public ProjetService(ProjetRepository repository, ProjetMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}
@Transactional(readOnly = true)
public List<ProjetResponse> findAll() {
return repository.findAll().stream()
.map(mapper::toResponse)
.toList();
}
@Transactional(readOnly = true)
public ProjetResponse findById(Long id) {
return repository.findById(id)
.map(mapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("Projet", id));
}
public ProjetResponse create(ProjetRequest request) {
Projet projet = mapper.toEntity(request);
return mapper.toResponse(repository.save(projet));
}
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new ResourceNotFoundException("Projet", id);
}
repository.deleteById(id);
}
}
@Transactional(readOnly = true) sur les lectures améliore les performances en signalant au provider JPA qu’aucune écriture n’est prévue.
Validation des entrées
public record ProjetRequest(
@NotBlank(message = "Le titre est obligatoire")
@Size(max = 200, message = "Le titre ne peut dépasser 200 caractères")
String titre,
@NotBlank(message = "La description est obligatoire")
String description,
@NotNull(message = "La date de début est obligatoire")
@FutureOrPresent(message = "La date doit être aujourd'hui ou dans le futur")
LocalDate dateDebut
) {}
@Valid dans le controller déclenche la validation. Les erreurs sont interceptées par le @ControllerAdvice.
Gestion globale des erreurs
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> e.getField() + " : " + e.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", String.join(", ", errors)));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Une erreur inattendue s'est produite"));
}
}
Un @RestControllerAdvice central évite de répéter la gestion d’erreurs dans chaque controller.
Tests avec MockMvc
@WebMvcTest(ProjetController.class)
class ProjetControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProjetService projetService;
@Test
void findById_returns200_whenProjetExists() throws Exception {
given(projetService.findById(1L))
.willReturn(new ProjetResponse(1L, "Mon projet", "Description"));
mockMvc.perform(get("/api/v1/projets/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.titre").value("Mon projet"));
}
@Test
void create_returns400_whenTitreIsBlank() throws Exception {
String body = """{"titre": "", "description": "Test", "dateDebut": "2026-01-01"}""";
mockMvc.perform(post("/api/v1/projets")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
}
Conclusion
Une API Spring Boot propre repose sur trois principes : séparation des couches (controller → service → repository), DTOs pour l’interface publique, et gestion centralisée des erreurs. Ces fondations permettent de faire évoluer l’API sans régression et de la tester en isolation à chaque couche.
Amine MEGDICHE
Développeur AEM & Java Full Stack — Freelance depuis 2013