JavaRush /Курсы /Spring REST & MVC /Граница input vs business validation

Граница input vs business validation

Spring REST & MVC
17 уровень, 3 лекция
Открыта

1. Граница input/business: валидатор не детектив

Когда проект растёт, появляется очень человеческое желание: “А давайте мы всё запретим аннотациями, чтобы контроллеры были чистые, а сервис вообще никогда не видел плохих запросов”. Звучит красиво, как “давайте жить дружно и не падать”. На практике это часто приводит к валидаторам, которые пытаются читать текущее состояние системы, и внезапно validation-слой превращается в маленький… сервисный слой, но хуже.

Представьте, что мы пишем Task Tracker API. У нас уже есть правила формата: длина title, размер tags, уникальность тегов, порядок dueAfter/dueBefore. Всё это реально можно проверить, глядя только на запрос. Но как только вы хотите проверить что-то вроде “задачу нельзя менять, если она ARCHIVED”, вы упираетесь в простой факт: в запросе нет статуса текущей задачи (и хорошо, что нет — это server-managed состояние).

То есть у нас появляется два класса правил:

Мы можем проверить вход без знания мира вокруг — это input validation.

И мы можем проверить, можно ли выполнить операцию с учётом текущего состояния ресурса и правил домена — это business validation.

Если эти классы смешать, получится валидатор, который лезет в репозиторий. Он начинает жить своей жизнью, создавать странные зависимости, а иногда ещё и делает ошибки “в стиле Spring”: циклические зависимости, неожиданные запросы к хранилищу, и весёлые N+1… даже без базы данных.

2. Два теста для разделения input/business

Самый надёжный способ не запутаться — не пытаться учить определения наизусть, а задавать себе два очень простых вопроса. Я люблю такие правила, потому что их можно применять даже когда мозг устал, а deadline не устал (он никогда не устает).

Первый вопрос звучит так: “Могу ли я принять решение, глядя только на этот запрос?” Если да — это кандидат на input validation. Если нужно читать задачу из репозитория, смотреть текущий статус, проверять существование ресурса, учитывать историю переходов — это уже не “качество запроса”, это “разрешённость операции”.

Второй вопрос: “Если завтра придёт такой же запрос, но состояние системы изменится — изменится ли результат проверки?” Если результат зависит от состояния системы, то это почти всегда business validation. Например, “title слишком короткий” завтра останется “слишком коротким”. А вот “архивную задачу нельзя менять” зависит от того, архивная она сейчас или нет.

Для закрепления удобно держать небольшую таблицу. Это не “список правил на зубрёжку”, а скорее мини-карта местности:

Критерий Input validation Business validation
На чём основано решение Только входной payload (path/query/body) Текущие данные системы (ресурс, состояние, правила домена)
Где живёт в проекте DTO + Bean Validation (@Valid, constraint’ы) Service / domain layer (обычные if, доменные методы, доменные исключения)
Типичный HTTP-смысл “Запрос сам по себе некорректен” → тяготеет к 400 Bad Request “Запрос корректен, но операция конфликтует с правилами/состоянием” → тяготеет к 409 Conflict
Можно ли вызывать репозитории/сервисы Нет (как базовая дисциплина) Да, обычно без этого никак
Типичные примеры title пустой, теги дублятся, dueAfter > dueBefore “нельзя изменить ARCHIVED”, “нельзя перейти TODODONE

Обратите внимание: таблица говорит “тяготеет к статусу”, а не “мы уже всё возвращаем”. Сегодня нам важнее правильно разместить правило, а не красиво завернуть ответ (это отдельная дисциплина).

3. Input validation в Task Tracker API

Input validation — это “фейс-контроль на входе” вашего API. Не в смысле “мы злые”, а в смысле “мы защищаем систему от мусора”. Здесь проверяется форма, диапазоны, базовые ограничения и согласованность самого входного сообщения. Самое приятное: эти проверки обычно детерминированы и не требуют походов в хранилище.

Начнём с простого и привычного: ограничения на поля.

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

public record TaskCreateRequest(
        // Обязательное поле + границы длины: это чистая проверка "формы" запроса
        @NotBlank
        @Size(min = 3, max = 120)
        String title,

        // Опционально, но ограничиваем максимальный размер
        @Size(max = 2000)
        String description,

        // Тоже "форма": ограничение по длине, без обращения к системе
        @Size(max = 80)
        String assigneeName,

        // Уникальность внутри payload (например, "bug" и "Bug") — это input validation
        @UniqueTagsIgnoreCase
        @Size(max = 10)
        List<@NotBlank @Size(max = 30) String> tags
) {}

Здесь всё про “сам запрос”: длины, обязательность, формат коллекции. Ни один из этих пунктов не требует знать, какие задачи уже есть, какой статус у конкретной задачи, и что там происходит в репозитории. Если клиент прислал title = "", это плохой запрос при любой погоде, в любом часовом поясе, и даже если сервер сегодня в хорошем настроении.

Теперь пример чуть хитрее: cross-field правило. Вчера мы уже видели, что “диапазон дат” нельзя выразить одной аннотацией на одном поле.

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.validation.ValidTaskDueDateRange;

import java.time.LocalDate;

// Проверка сразу пары полей: отдельно по одному полю такое правило не выразить
@ValidTaskDueDateRange
public record TaskSearchCriteria(
        LocalDate dueAfter,
        LocalDate dueBefore
) {}

И валидатор (коротко, по сути):

package com.example.tasktracker.domain.validation;

import com.example.tasktracker.api.dto.request.TaskSearchCriteria;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ValidTaskDueDateRangeValidator implements ConstraintValidator<ValidTaskDueDateRange, TaskSearchCriteria> {

    @Override
    public boolean isValid(TaskSearchCriteria value, ConstraintValidatorContext context) {
        // Если критерий не задан целиком — не "ломаем" запрос: это валидный кейс поиска без диапазона
        if (value == null || value.dueAfter() == null || value.dueBefore() == null) {
            return true;
        }

        // Основное правило: начало диапазона не должно быть после конца
        return !value.dueAfter().isAfter(value.dueBefore());
    }
}

Это тоже input validation. Почему? Потому что правило полностью вычисляется из входа. Нам не нужно “доставать задачу” — мы проверяем, что критерий поиска сам по себе логичен.

Теперь про 400 Bad Request. В рамках смысловой модели HTTP именно 400 лучше всего описывает ситуацию “ваш запрос нельзя обработать, потому что он некорректен”. Вы как сервер можете быть очень старательным, но пустой title вы не “угадаете” — вы не телепат.

Чтобы почувствовать разницу, посмотрите на два примера входа. Оба — “плохие”, но по-разному.

Пример “плохой формы” (кандидат на 400): дубли тегов внутри одного запроса.

{
  "title": "Fix prod",
  "tags": ["bug", "Bug"]
}

Мы не обязаны лезть в репозиторий: уже в рамках одного payload видно, что это противоречит правилам.

4. Business validation и семантика 409

Business validation начинается там, где заканчивается “проверка формы” и начинается “проверка возможности операции”. Запрос может быть идеально сформирован: правильные типы, правильные строки, идеальные даты… но операция всё равно может быть запрещена правилами домена. И это не каприз сервера, а часть контракта.

Ключевая мысль: business validation почти всегда требует текущего состояния ресурса. А значит, почти всегда живёт в сервисе (или рядом с ним в доменной логике), потому что именно сервис достаёт ресурс и управляет операцией.

Самый классический пример из нашего проекта: архивную задачу нельзя менять. Чтобы проверить это, нужно получить задачу и увидеть её статус.

Минимальный доменный исключительный тип (пока без “красивого HTTP”, просто смысл):

package com.example.tasktracker.domain.exception;

// Универсальное исключение "правило домена нарушено"
public class BusinessRuleViolationException extends RuntimeException {

    public BusinessRuleViolationException(String message) {
        // Сообщение здесь — про смысл правила, а не про формат входного JSON
        super(message);
    }
}

А теперь правило, уже на стороне сервиса:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;

public final class TaskRules {

    private TaskRules() {}

    public static void assertEditable(Task task) {
        // Это бизнес-проверка: нужна текущая задача и её статус (server-managed состояние)
        if (task.status() == TaskStatus.ARCHIVED) {
            throw new BusinessRuleViolationException("Archived task cannot be changed");
        }
    }
}

Почему это не input validation? Потому что запрос на изменение задачи вообще может не содержать status. И правильно: статус — часть текущего состояния задачи, а не форма запроса.

Второй пример: переход статусов. Новый статус может быть валидным enum значением (DONE, ARCHIVED и т.д.), то есть с точки зрения input validation всё ок: клиент прислал существующее значение. Но операция “поставить статус ARCHIVED” может быть запрещена, если задача сейчас не DONE.

Короткий helper для переходов:

package com.example.tasktracker.domain.model;

public final class TaskStatusTransitions {

    private TaskStatusTransitions() {}

    public static boolean canMove(TaskStatus from, TaskStatus to) {
        // Явно фиксируем разрешённые переходы — это доменная логика, не "валидация формы"
        return switch (from) {
            case TODO -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.BLOCKED;
            case IN_PROGRESS -> to == TaskStatus.BLOCKED || to == TaskStatus.DONE;
            case BLOCKED -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.DONE;
            case DONE -> to == TaskStatus.ARCHIVED;
            case ARCHIVED -> false;
        };
    }
}

И бизнес-проверка в сервисе может выглядеть так (без деталей репозитория, только смысл):

import com.example.tasktracker.domain.exception.BusinessRuleViolationException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatusTransitions;

public void assertStatusTransition(Task task, TaskStatus newStatus) {
    // Запрос может быть "валиден" по enum, но операция всё равно может быть запрещена состоянием задачи
    if (!TaskStatusTransitions.canMove(task.status(), newStatus)) {
        throw new BusinessRuleViolationException("Invalid status transition");
    }
}

Вот это классический 409 Conflict по смыслу: запрос структурно нормальный, но конфликтует с текущим состоянием ресурса и правилами переходов.

Если хочется запомнить одной фразой: input validation отвечает “можно ли понять запрос”, business validation отвечает “можно ли выполнить операцию”.

5. Пайплайн: DTO проверили — сервис решил

Чтобы это не осталось теорией, полезно увидеть общий маршрут запроса в приложении. Spring MVC и Bean Validation уже дают нам “первый кордон” проверок, но именно сервис должен быть местом, где принимаются доменные решения. Контроллер в этой истории — курьер: он принимает посылку, проверяет упаковку и передаёт дальше, но не решает судьбу товара.

Ниже схема потока (упрощённо, без внутренних деталей Spring):

flowchart TD
    A[HTTP Request] --> B[Binding: path/query/body -> Java types]
    B --> C[Input validation: Bean Validation on DTO]
    C -->|OK| D[Controller]
    D --> E[Service]
    E --> F[Business validation using current state]
    F -->|OK| G[Apply operation, save to repo]
    C -->|Failed| H[Bad request semantics: 400]
    F -->|Violation| I[Conflict semantics: 409]

Теперь кусочек контроллера. Обратите внимание: тут “магия” не нужна — достаточно @Valid, и input validation отработает до входа в сервис.

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.service.TaskService;
import jakarta.validation.Valid;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @PostMapping
    public void create(
            // @Valid запускает input validation по аннотациям DTO ещё до вызова сервиса
            @Valid @RequestBody TaskCreateRequest request
    ) {
        // Контроллер не решает доменные правила — он передаёт "упакованный" запрос дальше
        taskService.create(request);
    }
}

Да, тут сервис принимает request DTO — в реальном проекте вы часто будете маппить DTO в внутреннюю модель/команду через mapper (у нас для этого даже есть пакет api.mapper). Но для понимания границы validation это не критично: важен сам принцип “DTO проверили — дальше решает сервис”.

А вот пример того, как в сервисе выглядит “сначала достань состояние, потом реши можно ли”:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.exception.TaskNotFoundException;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.repository.TaskRepository;

public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public Task getTaskOrThrow(String taskId) {
        // Это не input validation: мы идём в хранилище и получаем текущее состояние системы
        return taskRepository.findById(taskId)
                .orElseThrow(() -> new TaskNotFoundException(taskId));
    }
}

И использование business rule:

import com.example.tasktracker.domain.model.Task;

public void updateTask(String taskId) {
    // 1) Сначала достаём ресурс (текущее состояние)
    Task task = getTaskOrThrow(taskId);

    // 2) Потом проверяем доменные ограничения для операции
    TaskRules.assertEditable(task);

    // 3) Дальше можно обновлять и сохранять
}

Это выглядит “слишком просто”, но в этом и сила: business validation не обязана быть оформлена как аннотация, чтобы быть полезной. Она обязана быть в правильном месте, рядом с операцией.

6. Серые зоны: ресурс, уникальность, время

В реальных проектах есть зоны, где легко начать спорить (или тихо сделать неправильно). И, честно, иногда спорить полезно, потому что это означает: вы начали думать об API как о контракте, а не как о наборе методов. Давайте разберём несколько серых зон так, чтобы у вас появилась опора, а не “мнение из чата”.

Первый кейс — существование ресурса. Например, POST /api/v1/tasks/{taskId}/comments: запрос комментария может быть идеальным по форме, но задача может не существовать. Это точно не input validation, потому что вы не можете определить это по payload. Это проверка состояния системы. Семантически это обычно ближе к 404 Not Found, а не к 409, потому что конфликта “с состоянием” нет — ресурса просто нет. Но важно другое: это проверка в сервисе, а не в ConstraintValidator.

Второй кейс — уникальность в системе. Например, “название задачи должно быть уникальным среди всех задач” (в нашем ТЗ такого нет, но как пример). Это почти всегда business validation, потому что вам нужно проверить “снаружи” текущий набор данных. Валидация уникальности внутри одного списка тегов — input validation. Уникальность по всему хранилищу — business validation.

Здесь часто делают анти-паттерн: пишут custom constraint @UniqueTaskTitle, который лезет в репозиторий. Технически Spring может это позволить (валидатор может быть bean’ом и получить зависимость), но концептуально вы превращаете validation-слой в бизнес-слой. Это делает обработку сложнее, усложняет зависимости и нередко приводит к неожиданным эффектам.

Как выглядит “соблазнительный, но опасный” пример:

import com.example.tasktracker.domain.repository.TaskRepository;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class ExistingTaskIdValidator implements ConstraintValidator<ExistingTaskId, String> {

    private final TaskRepository taskRepository;

    public ExistingTaskIdValidator(TaskRepository taskRepository) {
        // Валидатор внезапно зависит от хранилища/репозитория — это и есть "смешивание слоёв"
        this.taskRepository = taskRepository;
    }

    @Override
    public boolean isValid(String taskId, ConstraintValidatorContext context) {
        // Здесь валидация превращается в запрос к системе: результат зависит от данных в БД/хранилище
        return taskId != null && taskRepository.existsById(taskId);
    }
}

Почему это плохо как базовый путь? Потому что “валидность” запроса внезапно становится зависимой от хранилища. А валидация может запускаться часто, на разных уровнях, до того как вы вообще решили, какую операцию делаете. В результате вы можете получить лишние обращения к хранилищу и странные сценарии, где “валидация прошла, но дальше всё равно не получилось”.

Третий кейс — правила “зависят от времени”, например “dueDate не может быть в прошлом относительно сегодняшнего дня”. Формально вы не читаете состояние системы, но вы используете “текущее время”, а значит результат проверки может меняться. Чаще всего это всё ещё input validation (потому что не требует ресурса), но тут важно делать проверку аккуратно: использовать Clock, думать о timezone и помнить, что “вчера/сегодня” — штука скользкая. Если правило реально доменное (“нельзя ставить dueDate раньше createdAt задачи”), то оно уже зависит от состояния конкретной задачи и уходит в business validation.

7. Типичные ошибки на границе

Ошибка №1: пытаться “аннотациями” запретить то, чего нет в запросе.
Самая частая ловушка — желание написать @NotArchivedTask на update DTO. Но DTO не знает, архивна ли текущая задача, потому что это состояние хранится на сервере. В итоге валидатор либо начнёт лезть в репозиторий (и вы смешаете слои), либо будет бессмысленным и всегда “валидным”.

Ошибка №2: превращать ConstraintValidator в мини-сервис.
Как только валидатор получает репозиторий, логирование, конфиги и начинает “всё проверять”, вы теряете простую модель: “DTO → аннотация → чистая проверка”. Валидация становится не предсказуемым фильтром входа, а бизнес-операцией, замаскированной под аннотацию. Поддерживать это тяжело, а дебажить — ещё веселее (особенно когда веселье в проде).

Ошибка №3: маркировать бизнес-ограничения как 400 Bad Request.
Если запрос корректен, но операция запрещена правилами домена, 400 превращает ситуацию в “клиент прислал мусор”. Это ломает клиентскую логику: UI обычно по-разному реагирует на “исправь поля формы” и на “операция запрещена, потому что ресурс в таком состоянии”. Семантически такие случаи тяготеют к 409.

Ошибка №4: дублировать одно правило и в DTO, и в сервисе без причины.
Бывает соблазн “на всякий случай” проверить теги и в @UniqueTagsIgnoreCase, и ещё раз в сервисе. В итоге у вас два источника истины: они могут разойтись (например, один trim’ит пробелы, другой нет). Лучше иметь одно “главное место” правила и понимать, к какому типу оно относится.

Ошибка №5: путать “валидацию” с “нормализацией данных”.
Валидация отвечает “можно/нельзя”. Нормализация отвечает “как привести к канонической форме”. Иногда валидатору действительно нужно сделать лёгкую нормализацию (например, trim() для сравнения тегов), но если вы начинаете внутри валидатора менять смысл данных, вы усложняете контракт. Нормализация чаще уместна в mapper/service (где вы явно управляете преобразованием), а не как скрытый побочный эффект проверки.

1
Задача
Spring REST & MVC, 17 уровень, 3 лекция
Недоступна
Обновление заголовка с разделением input и business validation
Обновление заголовка с разделением input и business validation
1
Задача
Spring REST & MVC, 17 уровень, 3 лекция
Недоступна
Создание задачи с проверкой уникальности title в репозитории
Создание задачи с проверкой уникальности title в репозитории
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ