Bean Validation + глобальный ExceptionHandler
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Bean Validation (Jakarta Validation) ▸
Спецификация, реализация — Hibernate Validator. Spring Boot подключает автоматически.
2 Стандартные аннотации ▸
@NotNull // не null
@NotBlank // не null + не пустая строка + не только пробелы (для String)
@NotEmpty // не null + не пустая коллекция/массив/строка
@Size(min=3, max=200)
@Min(0) @Max(150)
@Email
@Pattern(regexp = "^[A-Z][a-z]+$")
@Past @PastOrPresent @Future @FutureOrPresent
@Positive @PositiveOrZero
@AssertTrue @AssertFalse
3 Уровни валидации ▸
- Поле (default):
@NotBlank String title; - Метод (валидация параметра):
public void create(@Valid @RequestBody CreateTaskRequest req) { ... } - Класс (cross-field):
class Passwords { @NotBlank String p1; @NotBlank String p2; }+ кастомный валидатор
4
Что делает @Valid?
▸
Запускает валидацию объекта. Если есть нарушения — кидает MethodArgumentNotValidException.
5 Глобальный обработчик исключений ▸
Без него Spring вернёт дефолтный JSON:
{"timestamp":"2026-01-15T10:00:00","status":400,"error":"Bad Request","path":"/api/tasks"}
С @ControllerAdvice мы контролируем формат ответа.
---
Практика: улучшаем CRUD
-
1Зависимости
dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") } -
2Аннотации в DTO
public record CreateTaskRequest( @NotBlank(message = "Title must not be blank") @Size(min = 3, max = 200, message = "Title must be 3-200 chars") String title, @Size(max = 5000, message = "Description must be at most 5000 chars") String description ) {} -
3Контроллер с
@Valid@PostMapping public ResponseEntitycreate(@Valid @RequestBody CreateTaskRequest req) { // Если req невалиден — сюда мы НЕ дойдём, вылетит MethodArgumentNotValidException TaskResponse created = service.create(req); URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") .buildAndExpand(created.id()).toUri(); return ResponseEntity.created(location).body(created); } -
4Стандартный ответ на ошибку (RFC 7807)
package com.taskflow.dto; import java.time.Instant; import java.util.List; public record ErrorResponse( int status, String error, String message, String path, Instant timestamp, ListfieldErrors ) { public record FieldError(String field, String message) {} } -
5
@ControllerAdvice— единый обработчикpackage com.taskflow.exception; import com.taskflow.dto.ErrorResponse; 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.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import java.time.Instant; import java.util.List; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntityhandleValidation(MethodArgumentNotValidException ex, WebRequest req) { List fieldErrors = ex.getBindingResult().getFieldErrors().stream() .map(this::mapFieldError) .toList(); ErrorResponse body = new ErrorResponse( 400, "Bad Request", "Validation failed", req.getDescription(false).replace("uri=", ""), Instant.now(), fieldErrors ); return ResponseEntity.badRequest().body(body); } @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleNotFound(ResourceNotFoundException ex, WebRequest req) { ErrorResponse body = new ErrorResponse( 404, "Not Found", ex.getMessage(), req.getDescription(false).replace("uri=", ""), Instant.now(), List.of() ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); } @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusiness(BusinessException ex, WebRequest req) { ErrorResponse body = new ErrorResponse( 409, "Conflict", ex.getMessage(), req.getDescription(false).replace("uri=", ""), Instant.now(), List.of() ); return ResponseEntity.status(HttpStatus.CONFLICT).body(body); } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex, WebRequest req) { ErrorResponse body = new ErrorResponse( 500, "Internal Server Error", "Unexpected error", req.getDescription(false).replace("uri=", ""), Instant.now(), List.of() ); // log full stack trace here return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); } private ErrorResponse.FieldError mapFieldError(FieldError fe) { return new ErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage()); } } -
6Кастомные исключения
package com.taskflow.exception; public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String resource, Long id) { super(resource + " with id=" + id + " not found"); } }package com.taskflow.exception; public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } } -
7Использование в
TaskServicepublic OptionalfindById(Long id) { return repo.findById(id).map(mapper::toResponse); } @Transactional public TaskResponse update(Long id, UpdateTaskRequest req) { Task task = repo.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Task", id)); mapper.updateEntityFromRequest(req, task); return mapper.toResponse(repo.save(task)); } @Transactional public void delete(Long id) { if (!repo.existsById(id)) { throw new ResourceNotFoundException("Task", id); } repo.deleteById(id); } -
8Тест через curl
# Невалидный title (пустая строка) $ curl -i -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title":"","description":"test"}' HTTP/1.1 400 Content-Type: application/json { "status": 400, "error": "Bad Request", "message": "Validation failed", "path": "/api/tasks", "timestamp": "2026-06-29T16:35:12.345Z", "fieldErrors": [ {"field": "title", "message": "Title must not be blank"} ] } # Несуществующий id $ curl -i -X DELETE http://localhost:8080/api/tasks/999 HTTP/1.1 404 {"status":404,"error":"Not Found","message":"Task with id=999 not found",...}---
Зачем это на собесе
После урока ты должен уметь ответить на:
Exception (а не 500 «stack trace в response») не раскрывает внутренности.ProblemDetail (Spring 6+) — стандарт RFC 7807, можно игнорировать свой ErrorResponse и использовать встроенный.5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: какую аннотацию выбирать для строк, объектов и коллекций.
Разбор внутри: стандартная validation и Spring-группы/валидация параметров.
Разбор внутри: кастомная аннотация и валидатор для проверки нескольких полей.
Разбор внутри: самый специфичный обработчик побеждает общий.
Разбор внутри: DTO даёт структуру, типы и bean validation.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 9Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 7