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

API: REST И КОНТРАКТЫ Валидация

Обязательно

Где валидировать данные

Java · Go · Node · Python

Граница проверяет форму и типы, домен защищает инварианты, база подстраховывает целостность без дублирования всех правил.

1. Граница отсекает неправильную форму

1 / 5

HTTP DTO проверяет обязательность, формат email, диапазон числа и тип данных. Ошибка возвращается как 400 до вызова сценария.

  • Проверка соответствует внешнему контракту.
  • Ошибка называет JSON-поле и правило.
  • Невалидный ввод не попадает в домен.
JSON inputDTO validationUse caseНеверный тип или формат → 400

2. Домен защищает инвариант независимо от входа

2 / 5

Заказ нельзя отменить после доставки, а списание не должно увести баланс ниже нуля. Эти правила нужны HTTP, CLI и consumer.

  • Доменный метод не полагается на DTO.
  • Нарушение возвращает доменную ошибку.
  • Правило живёт рядом с состоянием.
HTTPConsumerOrder.cancel()invariant409 conflict

3. Сервис координирует, но не копирует правила

3 / 5

Use case проверяет наличие ресурса и доступ, вызывает домен и инфраструктуру. Он не дублирует каждый доменный инвариант.

  • Not found и forbidden имеют отдельный смысл.
  • Составное правило принадлежит сценарию или доменному сервису.
  • Ошибка переводится в общий формат на границе.
BoundaryUse caseDomainDatabase constraintsshapecoordinationinvariants

4. База подстраховывает целостность

4 / 5

NOT NULL, CHECK, UNIQUE и FOREIGN KEY остаются последней линией, особенно при гонках и других писателях.

  • Ограничение не заменяет понятную проверку приложения.
  • Нарушение constraint переводится в доменный результат.
  • Гонка двух запросов проверяется на уровне хранения.
Two requestsUNIQUE + CHECKone commitone conflict

5. Правило имеет одного владельца

5 / 5

Формат можно повторить для удобства UI, но сервер всё равно проверяет. Доменный инвариант не размазывают по handler, SQL и клиенту.

  • DTO - контракт, домен - инвариант, БД - целостность.
  • Дублирование должно служить разной цели.
  • Ошибки возвращаются в одном ProblemDetails.
DTO: formatDomain: ruleDB: integrityОдно правило - один авторитетный слой

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

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

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-тела или очереди. Здесь уместны структурные проверки:

  • обязательность полей;
  • размеры и диапазоны;
  • формат: почта, телефон, шаблон строки.

В каждом языке для этого есть декларативный способ: аннотации 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 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;
    }
}
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.

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