Каждая точка входа в приложение получает данные снаружи — и они могут быть неверными. Вопрос не в том, проверять ли данные, а в том, в каком слое и с какой целью.
Проще всего это увидеть на одном эндпоинте: два запроса идут по одному и тому же стеку, но останавливаются в разных местах.
Запрос 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 приходит список полей, которые надо починить, по 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по имени ограничения.
Что почитать дальше
- Bean Validation: @NotNull, @Size, @Valid — стандартные аннотации и как их применять
- Кастомные сообщения и аннотации — собственные правила и локализованные тексты ошибок
- Ошибки REST API — как 422 и другие коды ошибок возвращаются клиенту