УРОК 8 / 30 0%

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. 1
    Зависимости
    
    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-validation")
    }
    
  2. 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. 3
    Контроллер с @Valid
    
    @PostMapping
    public ResponseEntity create(@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. 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,
        List fieldErrors
    ) {
        public record FieldError(String field, String message) {}
    }
    
  5. 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 ResponseEntity handleValidation(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. 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. 7
    Использование в TaskService
    
    public Optional findById(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. 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",...}
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

Единый формат ошибок — клиент пишет ОДИН обработчик, не 10 разных для разных endpoint'ов.
Безопасность: кастомный Exception (а не 500 «stack trace в response») не раскрывает внутренности.
ProblemDetail (Spring 6+) — стандарт RFC 7807, можно игнорировать свой ErrorResponse и использовать встроенный.

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
@NotBlank, @NotNull и @NotEmpty — чем отличаются?

Разбор внутри: какую аннотацию выбирать для строк, объектов и коллекций.

2
@Valid vs @Validated — когда нужен каждый?

Разбор внутри: стандартная validation и Spring-группы/валидация параметров.

3
Как сделать cross-field validation?

Разбор внутри: кастомная аннотация и валидатор для проверки нескольких полей.

4
Как Spring выбирает нужный @ExceptionHandler?

Разбор внутри: самый специфичный обработчик побеждает общий.

5
Почему Map плохо подходит для валидации входа?

Разбор внутри: DTO даёт структуру, типы и bean validation.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 9

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 7
🎉
Новый тир
Новый тир достигнут!