← назад к разделу

Каждая точка входа в приложение получает данные снаружи — и они могут быть неверными. Вопрос не в том, проверять ли данные, а в том, в каком слое и с какой целью.

Проще всего это увидеть на одном эндпоинте: два запроса идут по одному и тому же стеку, но останавливаются в разных местах.

POST /orders — оба запроса идут по одному стеку POST /orders — @Valid с @RequestBody сняли граница @Valid без @Valid домен инварианты база и внешние запрос B запрос A customerId: null, quantity: 0 customerId: C-42, quantity: 5 форму ещё не смотрелиформу ещё не смотрели стоп · 400@NotBlank и @Min(1) не прошлисюда уже не дошёл0 запросов, 0 транзакций прошёл насквозьпроверять некомуid = null пролетелформа — не его заботаNPE в репозитории · 500транзакция открыта зряпрошёл насквозьтело и так верное форма в порядкепропускает дальше следующий шаг стоп · 422на складе 3, просят 5остаток прочитан, записи нет оба тела уже в приложении — какое неверно, снаружи не видно A отбит формой: 400 и ни одного похода в базу B заполнен верно, но склад против: это 422, и это домен сняли @Valid — то же тело дошло до базы и упало 500 вместо 400

Запрос A с customerId: null и quantity 0 отбивается на границе: 400 и ни одного обращения к базе. Запрос B по форме безупречен, но на складе 3 штуки против запрошенных 5 — это уже домен и 422. Снять @Valid — и граница перестанет смотреть форму у обоих запросов: то же тело A доедет до репозитория, где вместо 400 получится 500.

Обязательно

Два уровня: граница и домен

Пустой email ловится по форме, а «этот email уже занят» только запросом в базу; первое знает контроллер, второе знает только сервис. Поэтому мест для валидации два, и у каждого свои проверки. Граница (HTTP-контроллер, Kafka-потребитель, gRPC-обработчик) — первая точка, где данные появляются в системе, и здесь проверяют форму. Домен (сервис, агрегат, доменный объект) — место, где данные обретают бизнес-смысл, и здесь проверяют правила.

У каждого уровня своя задача. Путаница возникает, когда одни и те же проверки размазывают по всем слоям без разбора.

Что проверяем на границе

На границе данные ещё «сырые» — строки, числа, флаги из HTTP-тела или очереди. Здесь уместны структурные проверки:

  • обязательность полей: @NotNull, @NotBlank;
  • размеры и диапазоны: @Size, @Min, @Max;
  • формат: @Email, @Pattern.
public record CreateOrderRequest(
    @NotBlank String customerId,
    @NotEmpty List<@NotNull Long> productIds,
    @Min(1) int quantity
) {}

Сами по себе аннотации ничего не проверяют, проверку включает контроллер:

@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
        @Valid @RequestBody CreateOrderRequest request) {
    ...
}

@Valid заставляет Spring проверить поля DTO до вызова метода контроллера. При ошибке летит MethodArgumentNotValidException, и Spring по умолчанию отвечает 400 Bad Request — запрос не прошёл проверку формы. Код 422 мы оставляем для другого случая: когда запрос заполнен верно, но нарушает правило предметной области («на складе нет столько товара»). Такое различение удобно клиенту: 400 — почини поля, 422 — поля в порядке, но так нельзя.

400 quantity: 0 @Min(1) не прошёл список полей почини поля 422 quantity: 5 на складе 3 из 5 код правила так нельзя

Два ответа глазами клиента: по 400 приходит список полей, которые надо починить, по 422 форма верна, а в теле код нарушенного правила.

Принцип: формат, обязательность, диапазон — на границе.

Что проверяем в домене

Часть правил нельзя проверить по одному полю. Они требуют знания контекста: состояния агрегата, данных из базы, бизнес-политик.

Примеры доменной валидации:

  • заказ нельзя отменить, если он уже отгружен;
  • скидка не может превышать стоимость корзины;
  • покупатель не может купить больше одной единицы товара по акционной цене.

Такие правила живут в домене рядом с состоянием, которое они защищают:

public class Order {
    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new DomainException("Нельзя отменить отгруженный заказ");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

Принцип: бизнес-инварианты — в домене.

В каком порядке это происходит

Неожиданность, на которую натыкаются все: проверки границы срабатывают после разбора тела, и до них дело может не дойти вовсе.

Порядок такой. Сервер читает байты, преобразователь разбирает JSON в объект — и вот здесь возможна первая ошибка: сломанный JSON или не тот тип значения. Тело не превратилось в объект, значит проверять нечего: прилетает HttpMessageNotReadableException, и это тоже 400, но другим путём и без списка нарушений по полям. Только если разбор удался, запускается проверка ограничений и собирается список нарушений.

Отсюда практическое следствие для контракта: у 400 два разных вида тела. С violations — когда объект собрался и не прошёл проверку; без violations, с одним общим сообщением — когда тело вообще не разобралось. Клиент должен быть готов к обоим, а сервер — обрабатывать оба исключения, иначе второй случай уйдёт в стандартном формате Spring.

И третий случай той же природы: неизвестное значение перечисления. Формально это ошибка разбора (значение не превращается в тип), поэтому по умолчанию она тоже приходит без списка полей — а хотелось бы указать поле и допустимые значения. Лечится тем, что перечисления принимают строкой и разбирают сами, как описано в статье про формат ответов.

Граница — это не только контроллер

Граница объявлена шире HTTP, и стоит показать, как те же проверки работают вне контроллера.

Метод сервиса. Ограничения на аргументах метода работают, если класс помечен как проверяемый:

@Service
@Validated
public class OrderService {
    public void reserve(@NotBlank String orderId, @Positive int quantity) { … }
}

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

Программная проверка. Там, где обёртки нет (обработчик сообщения, разбор файла), валидатор вызывают руками:

Set<ConstraintViolation<OrderMessage>> violations = validator.validate(message);
if (!violations.isEmpty()) { … }

Сообщение из очереди. Здесь принципиальное отличие: отвечать некому. Невалидное сообщение нельзя «вернуть клиенту с 400» — его надо снять с обработки, чтобы оно не блокировало очередь, и отправить в отдельную очередь недоставленных вместе с причиной. Повторять его бессмысленно: оно не станет валидным само. Это и есть главное следствие «граница шире HTTP»: правила те же, а реакция на нарушение зависит от того, кто по ту сторону.

Вызов по контракту с кодогенерацией (gRPC, сгенерированный клиент): часть проверок обеспечивает сам контракт (типы, обязательность), остальное — те же ограничения на объектах запроса.

Как доменное нарушение становится ответом

Связующее звено между throw new DomainException(...) и 422 в ответе стоит показать целиком, иначе схема остаётся обещанием.

// домен: правило и его нарушение
public class Order {
    public void cancel(String reason) {
        if (status == Status.SHIPPED) {
            throw new OrderCannotBeCancelledException(id, status);
        }
        …
    }
}

// граница приложения: превращаем в ответ
@RestControllerAdvice
public class DomainExceptionHandler {

    @ExceptionHandler(OrderCannotBeCancelledException.class)
    ProblemDetail onCannotCancel(OrderCannotBeCancelledException e) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
        problem.setTitle("Заказ нельзя отменить");
        problem.setDetail("Заказ в статусе " + e.status() + " отменить нельзя");
        problem.setProperty("code", e.code());       // ORDER_CANNOT_BE_CANCELLED
        return problem;
    }
}

Три вещи, которые делают эту связку работающей. Исключение несёт данные (идентификатор, статус, машинный код), а не только текст: иначе обработчику нечего положить в ответ. Сопоставление типа исключения со статусом живёт в одном месте — в обработчике, а не размазано по контроллерам. И домен ничего не знает про HTTP: он бросает своё исключение, а статус выбирает граница. Разбор устройства обработчика — в статье про единый обработчик.

Третий уровень: база как последний рубеж

Двух уровней недостаточно, и это видно на одном сценарии. Домен проверил «логина нет» — и в этот же миг параллельный запрос создал пользователя с тем же логином. Оба запроса прошли проверку, оба пишут, и второй падает на уникальном индексе.

Это гонка, и защититься от неё проверкой нельзя: между проверкой и записью всегда есть промежуток. Единственная настоящая защита — ограничение в базе, и правильная реакция — поймать его нарушение и превратить в осмысленный ответ.

@ExceptionHandler(DataIntegrityViolationException.class)
ProblemDetail onConflict(DataIntegrityViolationException e) {
    if (isUniqueViolation(e, "users_login_key")) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        problem.setProperty("code", "LOGIN_ALREADY_TAKEN");
        return problem;
    }
    throw e;   // остальное — технический сбой
}

Ответ здесь 409, а не 400: запрос был правильным, просто состояние изменилось. И обратите внимание на различение по имени ограничения — без него обработчик превратит любое нарушение целостности в «логин занят».

Практический вывод: проверка в домене остаётся (она даёт понятную ошибку в обычном случае), а ограничение в базе — гарантия. Оба нужны, и это не дублирование: у них разные роли.

Правила, которые требуют похода в базу

Ложная развилка «форма или инвариант» ломается на случае «такой купон существует». Формально это проверка структуры (значение из справочника), фактически — запрос в базу.

Такие правила не кладут в валидатор формы, и причин две. Первая: валидатор, который ходит в базу, превращает проверку тела в набор запросов (десять полей — десять запросов), и время ответа растёт непредсказуемо. Вторая: он всё равно не даёт гарантии, потому что между проверкой и использованием состояние меняется.

Правильное место — сценарий: он загружает купон один раз, и это же загруженное значение использует дальше. Нет купона — осмысленная ошибка (422 или 404, в зависимости от того, чей это объект); просрочен — ошибка правила. Подробнее, с оговорками про производительность, — в статье про свои ограничения и сообщения.

Почему не надо дублировать проверки

Соблазн понятен: продублировать @NotNull в сервисе «на всякий случай». Через полгода лимит длины комментария в контроллере подняли до 1000, а в сервисе остался 500, и запрос проходит границу и падает внутри с ошибкой, которую клиенту показать нечем. Так расходятся продублированные правила: рано или поздно они противоречат друг другу, непонятно, кто отвечает за проверку, а одно и то же условие живёт в трёх местах.

Короткая формула: граница проверяет форму данных, домен проверяет смысл данных.

Fail fast: ошибка на входе, а не в глубине

Структурные ошибки лучше обнаружить как можно раньше — до обращения к базе, до вызова внешних сервисов, до создания транзакции. Это называют fail fast.

Если @Valid стоит на контроллере, некорректный запрос отвергается немедленно — без лишней работы. Если проверку пропустить на границе, она всплывёт позже: NullPointerException в репозитории, ошибка внешнего API или повреждённые данные в базе — и отладить это намного сложнее.

Дополнительно: при первом чтении можно пропустить

Глубже: отказываться от лишнего: неизвестные поля, размер тела и глубинарасширенное

Граница проверяет то, что прислали. Не менее важно отказываться от того, чего не просили, и здесь умолчания Spring и Jackson на стороне удобства, а не безопасности.

Неизвестные поля. Jackson в Spring Boot по умолчанию молча пропускает поля, которых нет в DTO. Для ответов и событий это правильно (о совместимости говорит статья про версионирование), а для команд опасно дважды. Клиент с опечаткой emial не получает ошибку и удивляется, что почта не сохранилась. И массовое присваивание: если DTO команды это та же сущность, что и в базе, клиент присылает "role": "ADMIN" или "balance": 1000000, и поле заполняется, потому что оно есть в классе. Лечится двумя правилами: команды принимают отдельный DTO, где перечислены только те поля, которые клиенту можно менять, и никогда не сущность; и для командных DTO неизвестные поля запрещают, @JsonIgnoreProperties(ignoreUnknown = false) на классе или spring.jackson.deserialization.fail-on-unknown-properties: true глобально, если события читаются другим ObjectMapper. Неизвестное поле тогда даёт 400 с именем поля, о чём статья про Bean Validation.

Размер тела. Тело в сто мегабайт JSON приложение честно прочитает в память и упадёт. Предел ставят на входе: у прокси перед приложением (client_max_body_size в nginx) и в самом приложении. Для multipart это spring.servlet.multipart.max-request-size, для остального у Tomcat нет общего предела на тело, max-http-form-post-size касается только форм; поэтому у JSON-ручек предел это либо прокси, либо фильтр, который смотрит Content-Length и отвечает 413 до чтения тела. Предел записывают в контракт, как и остальные пределы.

Глубина и длина внутри JSON. Вложенность в десять тысяч уровней роняет разбор переполнением стека, строка в сто мегабайт съедает память ещё до проверки @Size. Jackson с версии 2.15 ограничивает это сам: глубина 1000, строка 20 миллионов символов, число 1000 знаков. Для API этого много, и пределы сужают через StreamReadConstraints в настройке ObjectMapper: глубина 50, строка в мегабайт. Проверка @Size(max = 100) на поле после этого работает как правило бизнеса, а не как единственная защита от мегабайта.

Прочее, что прислали, а вы не просили. Заголовки размером в килобайты (у Tomcat max-http-request-header-size, 8 КБ по умолчанию), тысячи параметров запроса (max-parameter-count), пути с .. и закодированными слэшами. Всё это ограничивает не ваш код, а настройка сервера, и её проверяют один раз при заведении сервиса, а не после инцидента.

Коротко

  • Граница (контроллер) проверяет формат, обязательность, диапазоны через @Valid и аннотации jakarta.validation.
  • Домен держит бизнес-инварианты — правила, которые зависят от состояния и контекста.
  • Дублировать одни и те же проверки во всех слоях вредно: правила расходятся, и непонятно, кто за них отвечает. Ограничение базы (NOT NULL, UNIQUE, CHECK) и инвариант агрегата дублированием не считаются: это последний рубеж там, куда приложение не смотрит.
  • Fail fast: структурные ошибки отсекай на входе, не в глубине стека.
  • Доменные исключения и HTTP-ошибки — разные вещи; маппинг происходит в @RestControllerAdvice.
  • Команды принимают отдельный DTO с разрешёнными полями и запрещают неизвестные; размер тела ограничивают на прокси или фильтром по Content-Length; глубину и длину строк JSON сужают через StreamReadConstraints.
  • Проверки границы работают после разбора тела: сломанный JSON и неизвестное значение перечисления дают 400 без списка нарушений, и обработчик для этого нужен отдельный.
  • Граница шире контроллера: @Validated на бине, программный вызов валидатора, а для сообщения из очереди реакция другая — в очередь недоставленных, а не «400 клиенту».
  • Доменное нарушение становится ответом через исключение с данными и машинным кодом плюс сопоставление типа со статусом в одном обработчике; домен про HTTP не знает.
  • Третий уровень — ограничение в базе: гонку проверкой не закрыть, поэтому нарушение уникальности ловят и превращают в 409 по имени ограничения.

Что почитать дальше