{ "index": 46, "slug": "editorial-2026-09-field-frontend-backend-boundary", "title": "Когда UI и API расходятся: как найти нарушенную границу", "excerpt": "Кнопка сообщает об успехе, экран показывает старое состояние, а API отвечает иначе. Разбираем четыре наблюдения, порядок проверки и границу, после которой нельзя делать выводы.", "contentHtml": "
Пользователь нажимает «Сохранить», видит сообщение об успехе, а после обновления страницы получает старые данные. В DevTools один ответ имеет статус 202, в логе сервера виден 409, а компонент уже переключился в состояние ready. Такой дефект выглядит как одна проблема, но может возникнуть в четырёх местах: намерение превратилось в другой запрос, сервер вернул другой контракт, адаптер потерял ответ или store отрисовал старую версию.
Цена ошибки — не только неверный текст на экране. Пользователь повторяет действие и может создать дубль. Оператор ищет причину в backend, хотя ответ не дошёл до store. Команда добавляет повторный запрос и получает гонку. Каждый следующий workaround увеличивает число состояний, которые нужно объяснять.
Тезис статьи простой: границу frontend и backend нужно проверять по наблюдаемым переходам, а не по месту, где впервые заметили симптом. Сравните intent, request, response и render input. Только после этого выбирайте слой исправления. Если один снимок отсутствует, вывод о причине ещё не доказан.
Взаимодействие проходит несколько границ. UI формирует команду из ввода пользователя. Клиентский слой превращает команду в HTTP-запрос. Gateway или backend возвращает статус, заголовки и representation. Адаптер проверяет ответ и строит view model. Store принимает её с учётом версии и передаёт компоненту. Компонент выбирает данные и рисует экран.
Эти шаги не взаимозаменяемы. HTTP 204 означает успешное выполнение без representation в ответе. Он не доказывает, что компонент уже получил новую read model. HTTP 409 означает конфликт состояния или команды, а не сетевой timeout. Успешное завершение обработчика click не означает, что бизнес-операция завершилась.
Для расследования достаточно безопасных полей: имя операции, класс входа, шаблон маршрута, идентификатор запроса, статус, Content-Type, результат проверки схемы и версия view model. Не нужно писать в лог тело ответа целиком. Идентификатор и хэш нормализованного класса часто связывают события без копирования персональных данных.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| На экране старое значение, ответ содержит новое | Адаптер или store не принял response | Сравнить response с render input и version | Исправить mapping, cache key или правило принятия версии |
| После повторного клика разные результаты | Гонка ответов или повторная mutation | Записать request id и задержать один ответ в тесте | Ввести idempotency key или отбросить устаревшую версию |
| UI показывает готово, сервер вернул 409 | Клиент считает любой ответ успехом | Проверить status и problem envelope | Разделить transport success и domain rejection |
| Поля исчезли после загрузки | Неверный Content-Type или форма body | Проверить media type и schema validation | Остановить адаптер на невалидном payload |
| curl и браузер дают разные наблюдения | Разные cookie, кеш или render logic | Сопоставить запросы, затем проверить store | Не переносить вывод curl на UI без render input |
Рассмотрим учебный пример. Сервер возвращает JSON с состоянием заказа. UI должен показывать кнопку retry, если синхронизация обязательна. Ошибка появляется, когда обработчик проверяет только факт получения ответа и ставит ready, не разобрав тело.
type OrderScreen = { status: 'ready' | 'blocked'; allowedActions: string[]; messageCode: string; version: number; }; function toScreenModel(response: Response, body: unknown): OrderScreen { if (!response.ok) throw new Error('domain-or-transport-failure'); const value = body as Partial<OrderScreen>; if (value.status !== 'ready' && value.status !== 'blocked') throw new Error('invalid-screen-contract'); return { status: value.status, allowedActions: Array.isArray(value.allowedActions) ? value.allowedActions : [], messageCode: typeof value.messageCode === 'string' ? value.messageCode : 'unknown', version: typeof value.version === 'number' ? value.version : 0 }; }Код показан только как учебная схема. Он не подтверждает поведение конкретного API и не заменяет схему валидации. В реальном приложении не следует молча подставлять version 0, если версия обязательна: лучше остановить переход и отправить безопасный диагностический сигнал.
Store должен принять модель только если она не старше уже принятой. Временная метка не решает задачу: часы процессов могут расходиться, а более поздний ответ может относиться к более раннему чтению. Версия, sequence number или серверное правило порядка дают проверяемое условие.
function accept(current: OrderScreen | undefined, next: OrderScreen) { if (current && next.version < current.version) return current; return next; }Это учебный отрицательный путь: устаревший ответ не меняет экран. Если API не выдаёт версию, не выдумывайте её на клиенте. Сначала определите, допускает ли контракт чтение последнего состояния, нужен ли повторный fetch или достаточно локального подтверждения. Optimistic UI может показать, что нажатие принято. Он не должен выдавать это за подтверждённое состояние ресурса.
Network в браузере показывает запрос и ответ конкретного user agent. Он помогает проверить метод, маршрут, статус, заголовки и тело. Он не показывает, какой объект передали selector или memoized компоненту.
curl повторяет HTTP-обмен с указанными заголовками. Он не воспроизводит cookie policy браузера, отмену запроса при unmount и порядок двух ответов. Лог backend подтверждает обработку на сервере, но не подтверждает, что браузер получил тот же response. Snapshot DOM показывает итог, но не говорит, откуда пришло значение.
Учебная команда для чтения тестового ресурса:
curl --fail-with-body --silent --show-error -H 'Accept: application/json' -H 'X-Request-Id: req-test-42' 'https://api.example.test/orders/42' | jq '{status, allowedActions, messageCode, version}'Здесь фиктивные host и идентификатор. Команда предназначена для чтения тестового ресурса. Не повторяйте mutation, пока не проверили идемпотентность и последствия. Если endpoint требует авторизацию, используйте тестовый токен с ограниченным сроком. Не помещайте секрет в shell history, статью или задачу.
Кеш обычно даёт повторяемость: один и тот же ключ возвращает прежнюю версию. Сравните request key, заголовки кеша, revision и источник данных. Не называйте кеш причиной, пока повторный запрос с новым ключом не меняет наблюдение.
Гонка зависит от порядка. Запрос A ушёл первым, B — вторым, но B вернулся раньше. Если store принимает ответы без проверки версии или актуальности запроса, A перезапишет более новое состояние. В тесте задержите только один ответ. Если результат меняется вместе с задержкой, гипотеза о race получила проверку.
Отдельно проверьте отмену запроса. Компонент мог размонтироваться, adapter мог получить AbortError, а локальный optimistic patch остался. В этом случае отсутствие response не доказывает отказ backend. Оно означает только, что текущий слой не получил наблюдаемого ответа.
Остановитесь, если следующий вывод требует неполученных данных. Так бывает, когда нужен production body с персональными полями, закрытый лог или повторная команда с неизвестным эффектом. Попросите владельца системы дать redacted response, безопасный correlation id или воспроизводимый тестовый запрос.
Остановка — точная граница доказательства. Нельзя объявлять кеш виноватым, если у вас есть только скриншот экрана. Нельзя обвинять backend, если вы не проверили request. Нельзя чинить selector, если render input уже неверен.
Отрицательный путь важен и для автоматической проверки. Невалидный Content-Type должен остановить адаптер. Устаревшая version не должна менять store. 409 должен вести к прикладному сообщению, а не к общему «ошибка сети». Отсутствующий response должен иметь отдельный статус диагностики, а не маскироваться под stale UI.
Протокол не заменяет distributed tracing, contract testing, авторизацию и security review. Он не решает проблему очереди одной HTTP-карточкой: для асинхронной команды нужны message id, статус обработки и правило повторов. Он также не разрешает логировать тело ответа целиком.
Критерий готовности проверяем так: для учебного сценария и отрицательного сценария можно связать intent, request, response и render input по безопасному идентификатору; невалидный ответ не меняет экран; устаревшая версия не перезаписывает новую; 409 получает отдельное прикладное состояние; команда может назвать следующий шаг или остановиться при нехватке данных. Это проверяемое свойство границы, а не обещание production-результата.