{ "index": 11, "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", "contentHtml": "
Сервис принимает запрос на изменение заказа. JSON проходит проверку схемы: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.
Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, имеет ли документ допустимую форму. Чистая доменная проверка отвечает, согласованы ли его поля. Сервис проверяет, можно ли применить документ к текущему состоянию и имеет ли пользователь право на действие. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке не нужны база, часы, права или другой запрос, её можно держать на границе документа. Как только появляется внешний контекст, это уже правило операции.
\nПервый вопрос относится к форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа она всегда возвращает одинаковый ответ.
Второй вопрос относится к связи полей. Например, диапазон дат требует, чтобы from не был позже to. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Это правило можно выразить в схеме, если выбранный диалект и валидатор поддерживают нужную конструкцию. На практике его часто проще вынести в чистую функцию с отдельным тестом. Важно не место записи, а ясная граница ответственности.
Третий вопрос относится к состоянию. Документ может быть безупречным, но ресурс уже изменился. Версия revision: 4 устарела, купон исчерпан, а роль пользователя не позволяет перевести заказ в новый статус. Такой отказ нельзя исправить изменением JSON-типа. Клиенту нужно перечитать ресурс, показать конфликт или прекратить операцию.
| Слой | Что проверяем | Пример отказа | Где выполнять |
|---|---|---|---|
| Форма | Тип, обязательность, диапазон, enum | limit не является integer | JSON Schema или валидатор на входе |
| Инвариант | Связь нескольких полей | from позже to | Чистая доменная функция |
| Состояние | Актуальность и доступность ресурса | Ревизия уже изменилась | Сервис, репозиторий, транзакция |
| Право | Разрешение на операцию | Роль не может отменить заказ | Авторизация до изменения состояния |
Схема полезна там, где нужно зафиксировать форму документа и дать одинаковую проверку нескольким потребителям. Она задаёт типы, обязательные ключи, диапазоны, шаблоны, перечисления и структуру вложенных объектов. Она также делает контракт читаемым для инструментов. OpenAPI 3.1 использует модель Schema Object, совместимую с JSON Schema 2020-12 с оговорёнными изменениями, поэтому форму HTTP-запроса удобно держать рядом с описанием endpoint.
\nСхема не знает, что запись существует именно сейчас. Она не проверяет доступ к строке базы, не сравнивает версию с версией в хранилище и не гарантирует, что внешний сервис ответил успешно. Даже формат даты не равен бизнес-смыслу даты: строка может быть корректной датой, но лежать за пределами периода, в котором разрешена операция.
\nНеизвестные поля требуют отдельного решения. Закрытая схема с запретом лишних ключей ловит опечатку, но может помешать расширению контракта. Открытая схема облегчает добавление полей, но пропускает ошибочный ключ, который клиент потом молча проигнорирует. Выберите модель для конкретного endpoint и закрепите её тестом. Не меняйте это поведение случайно при обновлении валидатора.
\nНиже — самостоятельный учебный фрагмент без HTTP-сервера и базы данных. Он показывает порядок вычислений, а не готовый production-код. Первая функция проверяет форму фильтра. Вторая проверяет связь полей. Обе функции чистые: их результат зависит только от переданного объекта.
\nconst 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.
Для изменения ресурса добавляется третий шаг. Клиент отправляет ожидаемую ревизию. Сервис читает текущую ревизию внутри транзакции и применяет изменение только при совпадении. Если в хранилище уже другая версия, сервис возвращает конфликт. Простая проверка до транзакции не защищает от гонки: другой запрос может изменить запись после чтения.
\nasync 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Валидатор принимает ключ, а клиент его не использует | Схема открыта или контракт не обновили | Сверить unknown-key policy и чтение поля | Закрыть объект либо явно документировать расширение |
| Правильный JSON получает 400 | Ошибка состояния скрыта под ошибкой формы | Разделить логи shape, invariant и state | Вернуть отдельный класс ошибки и исправить retry |
| Повторный запрос иногда меняет уже изменённый ресурс | Нет идемпотентности или проверки ревизии | Повторить команду при конкурентном обновлении | Добавить idempotency key или условие версии |
| Тест схемы проходит, endpoint падает | Схема не покрывает runtime-ветку | Вызвать реальный handler с теми же данными | Добавить интеграционный тест на границе |
| Клиент бесконечно повторяет запрос | Конфликт обозначен как временная ошибка | Проверить статус и тело ответа | Различить retryable отказ и конфликт ресурса |
validate: сначала определите документ.Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от времени и нескольких систем. В таких случаях нужно явно описать границу и риск, а не расширять JSON Schema до роли универсального движка правил.
\nСтатус HTTP тоже не заменяет доменный контракт. 409 Conflict подходит для конфликта с текущим состоянием ресурса, но тело ответа должно объяснить, что проверил сервис и что может сделать клиент. 422 может обозначать семантически неприемлемый документ, если это решение согласовано в API. Важна не магическая цифра, а стабильное различие между исправлением входа и разрешением конфликта.
Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат уже скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, а конкурентное изменение ресурса не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов.
\nРешение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, авторизация отклоняет запрещённого актёра, а транзакция отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий тоже проходит через тот же порядок. Если один из этих вопросов пока отвечает только фразой «валидатор всё проверит», граница ещё не проведена.
\n