{ "index": 11, "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", "contentHtml": "

Сервис принимает запрос на изменение заказа. JSON проходит проверку схемы: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.

\n

Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, имеет ли документ допустимую форму. Чистая доменная проверка отвечает, согласованы ли его поля. Сервис проверяет, можно ли применить документ к текущему состоянию и имеет ли пользователь право на действие. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке не нужны база, часы, права или другой запрос, её можно держать на границе документа. Как только появляется внешний контекст, это уже правило операции.

\n

Три вопроса вместо одного «valid»

\n

Первый вопрос относится к форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа она всегда возвращает одинаковый ответ.

\n

Второй вопрос относится к связи полей. Например, диапазон дат требует, чтобы from не был позже to. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Это правило можно выразить в схеме, если выбранный диалект и валидатор поддерживают нужную конструкцию. На практике его часто проще вынести в чистую функцию с отдельным тестом. Важно не место записи, а ясная граница ответственности.

\n

Третий вопрос относится к состоянию. Документ может быть безупречным, но ресурс уже изменился. Версия revision: 4 устарела, купон исчерпан, а роль пользователя не позволяет перевести заказ в новый статус. Такой отказ нельзя исправить изменением JSON-типа. Клиенту нужно перечитать ресурс, показать конфликт или прекратить операцию.

\n
Слой проверки и его граница
СлойЧто проверяемПример отказаГде выполнять
ФормаТип, обязательность, диапазон, enumlimit не является integerJSON Schema или валидатор на входе
ИнвариантСвязь нескольких полейfrom позже toЧистая доменная функция
СостояниеАктуальность и доступность ресурсаРевизия уже измениласьСервис, репозиторий, транзакция
ПравоРазрешение на операциюРоль не может отменить заказАвторизация до изменения состояния
\n

Что именно описывает JSON Schema

\n

Схема полезна там, где нужно зафиксировать форму документа и дать одинаковую проверку нескольким потребителям. Она задаёт типы, обязательные ключи, диапазоны, шаблоны, перечисления и структуру вложенных объектов. Она также делает контракт читаемым для инструментов. OpenAPI 3.1 использует модель Schema Object, совместимую с JSON Schema 2020-12 с оговорёнными изменениями, поэтому форму HTTP-запроса удобно держать рядом с описанием endpoint.

\n

Схема не знает, что запись существует именно сейчас. Она не проверяет доступ к строке базы, не сравнивает версию с версией в хранилище и не гарантирует, что внешний сервис ответил успешно. Даже формат даты не равен бизнес-смыслу даты: строка может быть корректной датой, но лежать за пределами периода, в котором разрешена операция.

\n

Неизвестные поля требуют отдельного решения. Закрытая схема с запретом лишних ключей ловит опечатку, но может помешать расширению контракта. Открытая схема облегчает добавление полей, но пропускает ошибочный ключ, который клиент потом молча проигнорирует. Выберите модель для конкретного endpoint и закрепите её тестом. Не меняйте это поведение случайно при обновлении валидатора.

\n
\"Матрица
Один запрос проходит несколько независимых границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.
\n

Учебный пример: сначала форма, потом доменное правило

\n

Ниже — самостоятельный учебный фрагмент без HTTP-сервера и базы данных. Он показывает порядок вычислений, а не готовый production-код. Первая функция проверяет форму фильтра. Вторая проверяет связь полей. Обе функции чистые: их результат зависит только от переданного объекта.

\n
const filterSchema = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    limit: { type: 'integer', minimum: 1, maximum: 100 },\n    from: { type: 'string', format: 'date' },\n    to: { type: 'string', format: 'date' }\n  }\n};\n\nfunction validateRange(input) {\n  if (input.from && input.to && input.from > input.to) {\n    return { ok: false, reason: 'from-after-to' };\n  }\n  return { ok: true };\n}\n\nconst shapeIsValid = validateWithSchema(filterSchema, {\n  limit: 25, from: '2027-09-10', to: '2027-09-12'\n});\nconst rangeIsValid = validateRange({\n  from: '2027-09-12', to: '2027-09-10'\n});
\n

В примере validateWithSchema обозначает вызов выбранной библиотекой JSON Schema. Это намеренное сокращение: конкретные API библиотек различаются, а задача фрагмента — показать две границы. Первый вызов отвечает за форму. Второй не пытается читать ресурс и не решает, есть ли право на поиск. В учебных данных нет утверждения о производительности или поведении в production.

\n

Для изменения ресурса добавляется третий шаг. Клиент отправляет ожидаемую ревизию. Сервис читает текущую ревизию внутри транзакции и применяет изменение только при совпадении. Если в хранилище уже другая версия, сервис возвращает конфликт. Простая проверка до транзакции не защищает от гонки: другой запрос может изменить запись после чтения.

\n
async function updateOrder(command, actor) {\n  const shape = validateCommandShape(command);\n  if (!shape.ok) return httpError(400, shape.reason);\n\n  const domain = validateCommandInvariant(command);\n  if (!domain.ok) return httpError(422, domain.reason);\n\n  if (!canEditOrder(actor, command.orderId)) {\n    return httpError(403, 'forbidden');\n  }\n\n  return db.transaction(async (tx) => {\n    const order = await tx.orders.getForUpdate(command.orderId);\n    if (!order || order.revision !== command.expectedRevision) {\n      return httpError(409, 'state-conflict');\n    }\n    return tx.orders.update(command.orderId, command.patch);\n  });\n}
\n

Этот код тоже ограничен учебной моделью. В реальной системе нужно проверить семантику блокировки, изоляцию транзакции, повторяемость команды и формат ошибки. Нельзя переносить фрагмент в production без проверки конкретного драйвера и правил домена.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
Валидатор принимает ключ, а клиент его не используетСхема открыта или контракт не обновилиСверить unknown-key policy и чтение поляЗакрыть объект либо явно документировать расширение
Правильный JSON получает 400Ошибка состояния скрыта под ошибкой формыРазделить логи shape, invariant и stateВернуть отдельный класс ошибки и исправить retry
Повторный запрос иногда меняет уже изменённый ресурсНет идемпотентности или проверки ревизииПовторить команду при конкурентном обновленииДобавить idempotency key или условие версии
Тест схемы проходит, endpoint падаетСхема не покрывает runtime-веткуВызвать реальный handler с теми же даннымиДобавить интеграционный тест на границе
Клиент бесконечно повторяет запросКонфликт обозначен как временная ошибкаПроверить статус и тело ответаРазличить retryable отказ и конфликт ресурса
\n

Порядок внедрения

\n
  1. Назовите endpoint, метод, формат запроса и ожидаемый ответ. Не начинайте с общей функции validate: сначала определите документ.
  2. Выпишите поля, типы, обязательность, enum, диапазоны и политику неизвестных ключей. Сохраните положительный и отрицательный пример.
  3. Отделите правила, которые используют только вход, от правил, которым нужны два поля, часы, права или данные ресурса.
  4. Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет и конфликт состояния не должны превращаться в один boolean.
  5. Проверьте отрицательный путь: устаревшая ревизия, повтор команды, отсутствие ресурса, запрещённая роль и сетевой отказ должны приводить к ожидаемому действию клиента.
  6. Запустите проверку на реальном handler и на выбранной библиотеке схем. Unit-тест чистой функции не заменяет интеграционный тест.
  7. Опишите безопасное расширение. Если добавляется поле или значение enum, проверьте старого потребителя и решите, нужен ли период совместимости.
  8. Зафиксируйте наблюдаемый критерий готовности: каждый класс отказа имеет тест, статус, причину и понятный следующий шаг.
\n

Ограничения механизма

\n

Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от времени и нескольких систем. В таких случаях нужно явно описать границу и риск, а не расширять JSON Schema до роли универсального движка правил.

\n

Статус HTTP тоже не заменяет доменный контракт. 409 Conflict подходит для конфликта с текущим состоянием ресурса, но тело ответа должно объяснить, что проверил сервис и что может сделать клиент. 422 может обозначать семантически неприемлемый документ, если это решение согласовано в API. Важна не магическая цифра, а стабильное различие между исправлением входа и разрешением конфликта.

\n

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

\n

Критерий готовности

\n

Решение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, авторизация отклоняет запрещённого актёра, а транзакция отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий тоже проходит через тот же порядок. Если один из этих вопросов пока отвечает только фразой «валидатор всё проверит», граница ещё не проведена.

\n

Проверяемые источники

" }