Каждая точка входа в приложение получает данные снаружи — и они могут быть неверными. Вопрос не в том, проверять ли данные, а в том, в каком слое и с какой целью.
Проще всего это увидеть на одном эндпоинте: два запроса идут по одному и тому же стеку, но останавливаются в разных местах.
Запрос A с customerId: null и quantity 0 отбивается на границе: 400 и ни одного обращения к базе. Запрос B по форме безупречен, но на складе 3 штуки против запрошенных 5 — это уже домен и 422. Снять @Valid — и граница перестанет смотреть форму у обоих запросов: то же тело A доедет до репозитория, где вместо 400 получится 500.
Два уровня: граница и домен
Пустой email ловится по форме, а «этот email уже занят» только запросом в базу; первое знает контроллер, второе знает только сервис. Поэтому мест для валидации два, и у каждого свои проверки. Граница (HTTP-контроллер, Kafka-потребитель, gRPC-обработчик) — первая точка, где данные появляются в системе, и здесь проверяют форму. Домен (сервис, агрегат, доменный объект) — место, где данные обретают бизнес-смысл, и здесь проверяют правила.
У каждого уровня своя задача. Путаница возникает, когда одни и те же проверки размазывают по всем слоям без разбора.
Что проверяем на границе
На границе данные ещё «сырые» — строки, числа, флаги из HTTP-тела или очереди. Здесь уместны структурные проверки:
- обязательность полей;
- размеры и диапазоны;
- формат: почта, телефон, шаблон строки.
В каждом языке для этого есть декларативный способ: аннотации Jakarta Validation в Java, теги validate в Go, декораторы class-validator в NestJS, модели pydantic в Python.
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@NotNull Long> productIds,
@Min(1) int quantity
) {}
type CreateOrderRequest struct {
CustomerID string `json:"customerId" validate:"required"`
ProductIDs []int64 `json:"productIds" validate:"required,min=1,dive,required"`
Quantity int `json:"quantity" validate:"min=1"`
}
export class CreateOrderRequest {
@IsNotEmpty() customerId: string;
@ArrayNotEmpty() @IsInt({ each: true }) productIds: number[];
@Min(1) quantity: number;
}
class CreateOrderRequest(BaseModel):
customer_id: str = Field(min_length=1)
product_ids: list[int] = Field(min_length=1)
quantity: int = Field(ge=1)
Сами по себе объявления ничего не проверяют, проверку включает обработчик на границе:
@PostMapping("/orders")
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request) {
...
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblem(w, http.StatusBadRequest, "тело не разобрано")
return
}
if err := h.validate.Struct(req); err != nil {
writeViolations(w, err.(validator.ValidationErrors))
return
}
...
}
@Post('orders')
create(@Body() request: CreateOrderRequest): Promise<OrderResponse> {
...
}
@router.post("/orders", status_code=201)
async def create(request: CreateOrderRequest) -> OrderResponse:
...
Проверка идёт до бизнес-кода. В Spring @Valid поднимает MethodArgumentNotValidException, и ответ по умолчанию 400 Bad Request; ValidationPipe в NestJS отвечает тем же 400, в Go ответ собирает сам обработчик, а FastAPI с pydantic по умолчанию отдаёт 422, и это стоит переопределить обработчиком RequestValidationError, чтобы держать то же различение: 400 — запрос не прошёл проверку формы. Код 422 мы оставляем для другого случая: когда запрос заполнен верно, но нарушает правило предметной области («на складе нет столько товара»). Такое различение удобно клиенту: 400 — почини поля, 422 — поля в порядке, но так нельзя.
Два ответа глазами клиента: по 400 приходит список полей, которые надо починить, по 422 форма верна, а в теле код нарушенного правила.
Принцип: формат, обязательность, диапазон — на границе.
Что проверяем в домене
Часть правил нельзя проверить по одному полю. Они требуют знания контекста: состояния агрегата, данных из базы, бизнес-политик.
Примеры доменной валидации:
- заказ нельзя отменить, если он уже отгружен;
- скидка не может превышать стоимость корзины;
- покупатель не может купить больше одной единицы товара по акционной цене.
Такие правила живут в домене рядом с состоянием, которое они защищают:
public class Order {
public void cancel() {
if (status == OrderStatus.SHIPPED) {
throw new DomainException("Нельзя отменить отгруженный заказ");
}
this.status = OrderStatus.CANCELLED;
}
}
func (o *Order) Cancel() error {
if o.status == StatusShipped {
return &DomainError{Message: "Нельзя отменить отгруженный заказ"}
}
o.status = StatusCancelled
return nil
}
export class Order {
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new DomainError('Нельзя отменить отгруженный заказ');
}
this.status = OrderStatus.CANCELLED;
}
}
class Order:
def cancel(self) -> None:
if self.status is OrderStatus.SHIPPED:
raise DomainError("Нельзя отменить отгруженный заказ")
self.status = OrderStatus.CANCELLED
Принцип: бизнес-инварианты — в домене.
В каком порядке это происходит
Неожиданность, на которую натыкаются все: проверки границы срабатывают после разбора тела, и до них дело может не дойти вовсе.
Порядок такой. Сервер читает байты, преобразователь разбирает JSON в объект — и вот здесь возможна первая ошибка: сломанный JSON или не тот тип значения. Тело не превратилось в объект, значит проверять нечего: прилетает HttpMessageNotReadableException, и это тоже 400, но другим путём и без списка нарушений по полям. Только если разбор удался, запускается проверка ограничений и собирается список нарушений.
Отсюда практическое следствие для контракта: у 400 два разных вида тела. С violations — когда объект собрался и не прошёл проверку; без violations, с одним общим сообщением — когда тело вообще не разобралось. Клиент должен быть готов к обоим, а сервер — обрабатывать оба исключения, иначе второй случай уйдёт в стандартном формате Spring.
И третий случай той же природы: неизвестное значение перечисления. Формально это ошибка разбора (значение не превращается в тип), поэтому по умолчанию она тоже приходит без списка полей — а хотелось бы указать поле и допустимые значения. Лечится тем, что перечисления принимают строкой и разбирают сами, как описано в статье про формат ответов.
Граница — это не только контроллер
Граница объявлена шире HTTP, и стоит показать, как те же проверки работают вне контроллера.
Метод сервиса. Ограничения на аргументах метода тоже можно объявить, а не проверять руками.
В Spring класс помечают как проверяемый:
@Service
@Validated
public class OrderService {
public void reserve(@NotBlank String orderId, @Positive int quantity) { … }
}
В Go обёрток нет, аргументы проверяют первой строкой метода:
func (s *OrderService) Reserve(ctx context.Context, orderID string, quantity int) error {
if orderID == "" || quantity <= 0 {
return ErrInvalidArgument
}
…
}
В Node ограничения вешают на объект команды и проверяют его перед работой:
class ReserveCommand {
@IsNotEmpty() orderId: string;
@IsPositive() quantity: number;
}
async reserve(cmd: ReserveCommand): Promise<void> {
await validateOrReject(cmd);
…
}
В Python то же делает @validate_call из pydantic:
@validate_call
def reserve(self, order_id: Annotated[str, StringConstraints(min_length=1)], quantity: PositiveInt) -> None:
…
В Spring механизм другой (проверка через обёртку вокруг бина), и исключение другое — ConstraintViolationException, а не то, что летит из контроллера; в NestJS validateOrReject бросает массив ValidationError, в pydantic @validate_call — свой ValidationError. Значит, и обработчик для него нужен отдельный, иначе клиент получит 500.
Программная проверка. Там, где обёртки нет (обработчик сообщения, разбор файла), валидатор вызывают руками:
Set<ConstraintViolation<OrderMessage>> violations = validator.validate(message);
if (!violations.isEmpty()) { … }
if err := validate.Struct(message); err != nil { … }
const violations = await validate(message);
if (violations.length > 0) { … }
try:
message = OrderMessage.model_validate(payload)
except ValidationError as e:
…
Сообщение из очереди. Здесь принципиальное отличие: отвечать некому. Невалидное сообщение нельзя «вернуть клиенту с 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;
}
}
// домен: правило и его нарушение
func (o *Order) Cancel(reason string) error {
if o.status == StatusShipped {
return OrderCannotBeCancelled{ID: o.id, Status: o.status}
}
…
}
// граница приложения: превращаем в ответ
func writeError(w http.ResponseWriter, err error) {
var cannotCancel OrderCannotBeCancelled
if errors.As(err, &cannotCancel) {
writeProblem(w, Problem{
Status: http.StatusUnprocessableEntity,
Title: "Заказ нельзя отменить",
Detail: "Заказ в статусе " + string(cannotCancel.Status) + " отменить нельзя",
Code: "ORDER_CANNOT_BE_CANCELLED",
})
return
}
…
}
// домен: правило и его нарушение
cancel(reason: string): void {
if (this.status === Status.SHIPPED) {
throw new OrderCannotBeCancelledException(this.id, this.status);
}
…
}
// граница приложения: превращаем в ответ
@Catch(OrderCannotBeCancelledException)
export class DomainExceptionFilter implements ExceptionFilter {
catch(e: OrderCannotBeCancelledException, host: ArgumentsHost) {
host.switchToHttp().getResponse().status(422).json({
status: 422,
title: 'Заказ нельзя отменить',
detail: `Заказ в статусе ${e.status} отменить нельзя`,
code: e.code, // ORDER_CANNOT_BE_CANCELLED
});
}
}
# домен: правило и его нарушение
def cancel(self, reason: str) -> None:
if self.status is Status.SHIPPED:
raise OrderCannotBeCancelled(self.id, self.status)
…
# граница приложения: превращаем в ответ
@app.exception_handler(OrderCannotBeCancelled)
async def on_cannot_cancel(request: Request, e: OrderCannotBeCancelled) -> JSONResponse:
return JSONResponse(status_code=422, content={
"status": 422,
"title": "Заказ нельзя отменить",
"detail": f"Заказ в статусе {e.status} отменить нельзя",
"code": e.code, # ORDER_CANNOT_BE_CANCELLED
})
Три вещи, которые делают эту связку работающей. Исключение несёт данные (идентификатор, статус, машинный код), а не только текст: иначе обработчику нечего положить в ответ. Сопоставление типа исключения со статусом живёт в одном месте — в обработчике, а не размазано по контроллерам. И домен ничего не знает про 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; // остальное — технический сбой
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "users_login_key" {
writeProblem(w, Problem{Status: http.StatusConflict, Code: "LOGIN_ALREADY_TAKEN"})
return
}
return err // остальное — технический сбой
@Catch(QueryFailedError)
export class ConflictFilter implements ExceptionFilter {
catch(e: QueryFailedError & { driverError: { code: string; constraint?: string } }, host: ArgumentsHost) {
if (e.driverError.code === '23505' && e.driverError.constraint === 'users_login_key') {
return host.switchToHttp().getResponse().status(409).json({ status: 409, code: 'LOGIN_ALREADY_TAKEN' });
}
throw e; // остальное — технический сбой
}
}
@app.exception_handler(UniqueViolation)
async def on_conflict(request: Request, e: UniqueViolation) -> JSONResponse:
if e.diag.constraint_name == "users_login_key":
return JSONResponse(status_code=409, content={"status": 409, "code": "LOGIN_ALREADY_TAKEN"})
raise e # остальное — технический сбой
Ответ здесь 409, а не 400: запрос был правильным, просто состояние изменилось. И обратите внимание на различение по имени ограничения — без него обработчик превратит любое нарушение целостности в «логин занят».
Практический вывод: проверка в домене остаётся (она даёт понятную ошибку в обычном случае), а ограничение в базе — гарантия. Оба нужны, и это не дублирование: у них разные роли.
Правила, которые требуют похода в базу
Ложная развилка «форма или инвариант» ломается на случае «такой купон существует». Формально это проверка структуры (значение из справочника), фактически — запрос в базу.
Такие правила не кладут в валидатор формы, и причин две. Первая: валидатор, который ходит в базу, превращает проверку тела в набор запросов (десять полей — десять запросов), и время ответа растёт непредсказуемо. Вторая: он всё равно не даёт гарантии, потому что между проверкой и использованием состояние меняется.
Правильное место — сценарий: он загружает купон один раз, и это же загруженное значение использует дальше. Нет купона — осмысленная ошибка (422 или 404, в зависимости от того, чей это объект); просрочен — ошибка правила. Подробнее, с оговорками про производительность, — в статье про свои ограничения и сообщения.
Почему не надо дублировать проверки
Соблазн понятен: продублировать проверку обязательного поля в сервисе «на всякий случай». Через полгода лимит длины комментария в контроллере подняли до 1000, а в сервисе остался 500, и запрос проходит границу и падает внутри с ошибкой, которую клиенту показать нечем. Так расходятся продублированные правила: рано или поздно они противоречат друг другу, непонятно, кто отвечает за проверку, а одно и то же условие живёт в трёх местах.
Короткая формула: граница проверяет форму данных, домен проверяет смысл данных.
Fail fast: ошибка на входе, а не в глубине
Структурные ошибки лучше обнаружить как можно раньше — до обращения к базе, до вызова внешних сервисов, до создания транзакции. Это называют fail fast.
Если валидация стоит на границе, некорректный запрос отвергается немедленно - без лишней работы. Если проверку пропустить, она всплывёт позже: ошибка типа в репозитории, отказ внешнего API или повреждённые данные в базе - и отладить это намного сложнее.
Глубже: отказываться от лишнего: неизвестные поля, размер тела и глубинарасширенное
Граница проверяет то, что прислали. Не менее важно отказываться от того, чего не просили: умолчания JSON-библиотек и HTTP-серверов часто выбирают удобство и совместимость, а не строгость входного контракта.
Неизвестные поля. Многие JSON-библиотеки по умолчанию пропускают поля, которых нет в модели команды. Для ответов и событий это помогает совместимости, а для команд опасно: клиент с опечаткой emial не получает ошибку и удивляется, что почта не сохранилась. Отдельная опасность - массовое присваивание: если принимать команду прямо в модель базы, клиент сможет заполнить служебные role или balance. Команды принимают отдельной моделью с разрешенными полями, а неизвестные запрещают настройкой своего стека: @JsonIgnoreProperties(ignoreUnknown = false) в Java, DisallowUnknownFields() в Go, forbidUnknownValues в class-validator, extra="forbid" в Pydantic.
Размер тела. Тело в сто мегабайт JSON приложение честно прочитает в память. Предел ставят у прокси (client_max_body_size в nginx) и в самом приложении до разбора тела: настройки multipart и HTTP-сервера в Java, http.MaxBytesReader в Go, limit у express.json() в Node, ограничения сервера или промежуточного слоя в Python. Ответ 413 и предел записывают в контракт.
Глубина и длина внутри JSON. Вложенность в десять тысяч уровней может уронить разбор, а строка в сто мегабайт съедает память ещё до проверки поля. Ограничения выставляют в JSON-парсере и на границе: глубину, длину строки и размер чисел выбирают по реальному контракту, а не по максимуму библиотеки. Проверка длины поля после разбора остается правилом бизнеса, но не заменяет защиту от огромного документа.
Прочее, что прислали, а вы не просили. Слишком большие заголовки, тысячи параметров запроса, пути с .. и закодированными слэшами. Всё это ограничивает настройка HTTP-сервера и прокси, и её проверяют при заведении сервиса, а не после инцидента.
Коротко
- Граница (контроллер) проверяет формат, обязательность и диапазоны средствами валидации своего стека.
- Домен держит бизнес-инварианты — правила, которые зависят от состояния и контекста.
- Дублировать одни и те же проверки во всех слоях вредно: правила расходятся, и непонятно, кто за них отвечает. Ограничение базы (
NOT NULL,UNIQUE,CHECK) и инвариант агрегата дублированием не считаются: это последний рубеж там, куда приложение не смотрит. - Fail fast: структурные ошибки отсекай на входе, не в глубине стека.
- Доменные исключения и HTTP-ошибки — разные вещи; их сопоставление происходит в общем обработчике ошибок.
- Команды принимают отдельной моделью с разрешёнными полями и запрещают неизвестные; размер тела ограничивают на прокси или в приложении до разбора; глубину и длину строк JSON сужают настройками парсера.
- Проверки границы работают после разбора тела: сломанный JSON и неизвестное значение перечисления дают
400без списка нарушений, и обработчик для этого нужен отдельный. - Граница шире контроллера:
@Validatedна бине, программный вызов валидатора, а для сообщения из очереди реакция другая — в очередь недоставленных, а не «400 клиенту». - Доменное нарушение становится ответом через исключение с данными и машинным кодом плюс сопоставление типа со статусом в одном обработчике; домен про HTTP не знает.
- Третий уровень — ограничение в базе: гонку проверкой не закрыть, поэтому нарушение уникальности ловят и превращают в
409по имени ограничения.
Что пощупать
Граница «формат запроса снаружи, смысл внутри» в практикуме remodov/marketplace-system видна на одной команде: скидка проверяется дважды, аннотацией на теле запроса и правилом в сущности, и тест домена дотягивается только до второй проверки. Стартовый сервис services/catalog-starter.
Код: product.
Код на Go: handler.go, product.go.
Код на Node: product.dto.ts, product.entity.ts.
Код на Python: router.py, model.py.
Сделаем сами
Ветка step-04-domain-rules — скидки нет, ProductRulesTest и ProductDiscountApiTest красные.
На Go: ветка step-03-commands-and-errors.
На Node: ветка step-03-commands-and-errors.
На Python: ветка step-03-commands-and-errors.
Что почитать дальше
- Bean Validation: @NotNull, @Size, @Valid — стандартные аннотации и как их применять
- Кастомные сообщения и аннотации — собственные правила и локализованные тексты ошибок
- Ошибки REST API — как 422 и другие коды ошибок возвращаются клиенту