8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 47,
|
||
"slug": "editorial-2026-09-mechanism-frontend-backend-boundary",
|
||
"title": "Состояние экрана не угадывают по статусу: разделяем query, command и ошибку",
|
||
"excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.",
|
||
"contentHtml": "<p>Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красный toast «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.</p>\n<p>Причина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение representation, отправку команды и объяснение отказа. Надёжная граница оставляет право менять screen state только за проверенной моделью. Query читает данные. Command просит изменить состояние. Error envelope сообщает, почему переход не состоялся и какой следующий шаг допустим.</p>\n<h2>Тезис: статус не является моделью экрана</h2>\n<p>Статус HTTP сужает множество возможных решений, но не выбирает состояние компонента в одиночку. Ответ 200 может содержать неизвестный для клиента статус. Ответ 204 подтверждает отсутствие тела, но не говорит, какую локальную модель нужно строить. Ответ 409 может требовать перечитать ресурс. Ответ 422 может подсветить поле формы. Ответ 429 может разрешать повтор только после паузы. Универсальный обработчик «не 2xx — ошибка, 2xx — успех» стирает эти различия.</p>\n<p>Разделите контекст по последствиям. Query не должен менять доменное состояние. Command не должен объявлять новую screen model только потому, что сервер принял запрос. Error envelope должен содержать машинный тип проблемы и безопасные данные для следующего шага. Текст для пользователя — ответственность адаптера UI, а не строка, которую компонент извлекает из свободного <code>detail</code>.</p>\n<figure><img src=\"/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg\" alt=\"Матрица ответственности связывает HTTP-результат, право UI и следующий шаг\" loading=\"lazy\" /><figcaption>Матрица связывает результат HTTP с разрешённым действием UI. Она не доказывает корректность конкретного API и не заменяет тесты.</figcaption></figure>\n<h2>Механизм границы</h2>\n<p>У каждой операции должны быть названы вход, форма результата и отрицательные переходы. Для query это обычно валидная representation или ошибка чтения. Для command возможны новая representation, 202 Accepted с идентификатором операции или 204 No Content. Эти ответы нельзя обрабатывать одной функцией. В 202 результат команды ещё не равен готовому состоянию ресурса. В 204 тела нет, поэтому запуск JSON parser — уже ошибка клиента.</p>\n<p>409 Conflict означает конфликт текущего состояния ресурса с запросом. Если версия записи устарела, UI может предложить перечитать данные и выбрать действие заново. Не стоит без изменения входа отправлять команду снова. 422 Unprocessable Content означает, что запрос синтаксически понятен, но содержимое не прошло прикладную проверку. Это путь к конкретному полю или правилу, а не сетевой сбой.</p>\n<p>429 Too Many Requests и 5xx могут быть временными, но их нельзя объединять в бесконечный retry. Политика зависит от идемпотентности команды, бюджета попыток, <code>Retry-After</code> и того, известен ли исход операции. Timeout особенно опасен: сервер мог принять команду, а ответ мог потеряться. В этом случае клиент не имеет права считать операцию не выполненной и безопасно повторять POST без договорённости о ключе идемпотентности.</p>\n<h2>Учебный пример: адаптер ответа</h2>\n<p>Ниже — ограниченный учебный пример. Он не обращается к сети, не проверяет реальную схему и не объявляет production-операцию успешной. Его задача — показать место, где transport result превращается в решение UI. В настоящем клиенте список допустимых статусов, problem types и действий должен следовать конкретному контракту API.</p>\n<pre><code>type UiDecision =\n | { kind: 'render'; model: ScreenModel }\n | { kind: 'read-again'; problemType: string }\n | { kind: 'field-error'; fields: Record<string, string> }\n | { kind: 'retry-later'; retryAfterSeconds?: number }\n | { kind: 'unknown'; reason: string };\n\nfunction decide(response: {\n status: number;\n body: unknown;\n retryAfter?: number;\n}): UiDecision {\n if (response.status === 204) {\n return { kind: 'read-again', problemType: 'no-representation' };\n }\n\n if (response.status === 409 && isProblem(response.body)) {\n return { kind: 'read-again', problemType: response.body.type };\n }\n\n if (response.status === 422 && isValidationError(response.body)) {\n return { kind: 'field-error', fields: response.body.fields };\n }\n\n if (response.status === 429 || response.status >= 500) {\n return { kind: 'retry-later', retryAfterSeconds: response.retryAfter };\n }\n\n if (response.status === 200 && isScreenModel(response.body)) {\n return { kind: 'render', model: response.body };\n }\n\n return { kind: 'unknown', reason: 'response-does-not-match-contract' };\n}</code></pre>\n<p>Важен отрицательный путь в конце. Неизвестный статус или форма тела не должны попадать в ветку <code>render</code> по умолчанию. Сгенерированный TypeScript-тип тоже не даёт такой гарантии: он описывает ожидаемый payload, но не проверяет фактический JSON во время выполнения. Runtime-проверка должна отделять корректную модель от данных, которые нельзя безопасно показать.</p>\n<h2>Query, command и ошибка на одном сценарии</h2>\n<p>Представим экран редактирования адреса. Query <code>GET /orders/42</code> возвращает модель заказа и разрешённые действия. Пользователь отправляет command <code>POST /orders/42/address</code>. Если сервер возвращает 200 с новой моделью, адаптер может передать её в store. Если сервер возвращает 202, store получает состояние «операция принята» и идентификатор отслеживания. Если сервер возвращает 204, клиент перечитывает заказ по правилу, которое явно указано контрактом.</p>\n<p>Если версия заказа устарела, API возвращает 409 с problem type <code>order.version-conflict</code>. UI показывает, что данные изменились, и предлагает перечитать их. Если индекс адреса неверен, API возвращает 422 и привязку ошибки к полю. Если ограничение частоты сработало, UI не очищает форму и не создаёт вторую команду: он показывает ограниченное сообщение и ждёт разрешённый момент повтора. Во всех трёх случаях компонент не придумывает доменное состояние из одного числа.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика границы между UI и API</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>После 2xx экран показывает новое состояние, но после reload данные старые</td><td>Command принята или завершилась без representation</td><td>Сверить статус, тело и момент повторного чтения</td><td>Разделить подтверждение команды и query</td></tr><tr><td>409 показывает общий сетевой toast</td><td>Все 4xx сведены к одной ветке</td><td>Проверить problem type и текущую версию ресурса</td><td>Предложить перечитать или разрешить конфликт</td></tr><tr><td>422 не подсвечивает поле</td><td>Адаптер читает только HTTP status</td><td>Проверить структуру ошибок и указатель поля</td><td>Преобразовать код поля в модель формы</td></tr><tr><td>После 429 запросы идут без остановки</td><td>Retry стал общей реакцией на отказ</td><td>Посчитать попытки и проверить Retry-After</td><td>Ввести бюджет повторов и паузу</td></tr><tr><td>После timeout пользователь повторяет команду вручную</td><td>Неизвестный исход назван отрицательным</td><td>Проверить idempotency key и способ узнать результат</td><td>Перечитать операцию или показать безопасное ожидание</td></tr><tr><td>Неизвестный JSON отображается как готовый экран</td><td>Валидация формы отсутствует или стоит после render</td><td>Проверить runtime schema до записи в store</td><td>Остановить render и передать техническую ошибку</td></tr></tbody></table></div>\n<h2>Порядок действий</h2>\n<ol><li>Опишите наблюдаемый симптом и цену ошибочного перехода.</li><li>Назовите операцию: query, command или получение результата фоновой операции.</li><li>Зафиксируйте метод, маршрут, статус, Content-Type и безопасный request id.</li><li>Определите, есть ли в ответе representation, problem detail или только подтверждение приёма.</li><li>Опишите переходы для 2xx, 204, 409, 422, 429, 5xx и неизвестного ответа.</li><li>Проверьте тело до того, как записывать его в screen state.</li><li>Для command отдельно проверьте повтор после timeout и правило идемпотентности.</li><li>Для 202 и 204 укажите, как клиент узнает актуальную модель.</li><li>Добавьте тест на отрицательный путь: неизвестный статус, неверную форму или отсутствующий обязательный тип.</li><li>Сопоставьте UI-действие с одним машинным кодом, а не со свободной строкой сообщения.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не назначает единственный статус для каждой бизнес-операции. Один API может использовать 409 для конфликта версии, другой — отдельный прикладной код внутри 409. Решение должно быть закреплено в контракте и одинаково понято всеми клиентами. Problem Details задаёт форму переносимого описания ошибки, но не выбирает локализацию, право доступа, retry policy или безопасное содержание полей.</p>\n<p>Граница также не решает проблему stale data сама по себе. Кэш, очередь, реплика и фоновая обработка требуют своих версий и сигналов. Если command запускает асинхронную работу, одной HTTP-карточки мало: нужны идентификатор операции, статус её обработки и путь к итоговой representation. Не маскируйте очередь под мгновенный 200.</p>\n<p>Не всякое различие нужно превращать в новый тип. Для простого чтения достаточно строгой схемы и понятного error path. Но если UI должен показать разные действия, контракт обязан назвать эти действия или стабильные коды, а не заставлять клиента разбирать английский текст <code>detail</code>. Учебный классификатор выше не заменяет security review, нагрузочное испытание и проверку реальной реализации.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Граница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, а неизвестный ответ останавливает render. Для command после timeout должен существовать отдельный путь узнать фактический результат.</p>\n<p>Если команда не может ответить на эти вопросы по контракту и тесту, исправление не завершено. Нельзя закрывать пробел общим toast или локальным флагом <code>isSuccess</code>. Готовность — это совпадение HTTP-семантики, проверенной модели и следующего действия пользователя. Только после этого компонент получает право менять экран.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — официальная спецификация семантики методов, представлений и статус-кодов HTTP.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9457: Problem Details for HTTP APIs</a> — официальный формат машинно-читаемых деталей ошибки для HTTP API.</li><li><a href=\"https://spec.openapis.org/oas/latest.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification</a> — официальное описание интерфейсов HTTP API, операций и вариантов ответов.</li></ul>"
|
||
}
|