
Когда в последний раз ты искал баг, который оказался не твоей ошибкой? А сколько раз ты делал это сегодня?
Я про те случаи, когда всё падает, консоль красная, ты перепроверяешь код в десятый раз и не понимаешь, где накосячил. А потом выясняется: бэк просто поменял контракт. Без предупреждения. Без обновлённого свагера. Просто «бесшумный рефакторинг».
Вот только рефачил не ты. А гадать, почему запросы падают, приходится тебе.
Это не история про плохой бэк. Это история про то, как устроены процессы в командах, где больше нескольких разработчиков, которые не сидят в одном чате 24/7. Руководители не держат в голове каждое минорное изменение. Бэк не всегда синхронизируется с фронтом. Документация либо отсутствует, либо устарела.
Помню конкретный случай. Зарелизили фичу на день позже, потому что бэк переименовал путь ручки и одно поле в ней. Полдня тестировщики гадали, почему фича работает не так, как задумано. Ещё полдня я перекапывал свой код, прежде чем полез в контракт и увидел, что путь теперь называется по-другому.
Несколько месяцев я работал с одним сервисом в такой парадигме. Искал несоответствия в контрактах, сверял пути, переписывал типы. В пиковые недели релизов у команды уходило около 25 часов не на фичи, а на вынужденную синхронизацию. И я решил, что с этим пора заканчивать. Причём не просто починить конкретную боль, а сделать шаг к автоматизации и унификации всего слоя взаимодействия с API.
Решение: Self-describing API
Я поднял вопрос о внедрении Self-describing API. Мне больше нравится термин «динамическая документация».
Звучит сложно. На деле - топорно и просто.
По сути, это обычный свагер (спецификация OpenAPI, которая описывает, как выглядит REST-ручка), который приходит в ответ на GET-запрос /api/dynamic-doc/. В ответе массив доступных ручек с их описанием в унифицированном (что важно: все ручки отдают описание в одной структуре, иначе фронт не сможет распарсить это одним кодом) формате.
[ { "name": "intern.list", "path": "api/interns", "method": "GET", "params": {} }, { "name": "intern.create", "path": "api/interns", "method": "POST", "params": { "coffeeLevel": { "type": "string", "required": true } } } ]
Что мы имеем в такой ручке: URL, метод, допустимые параметры и их дефолтные значения.
В типизации это выглядит наглядно:
interface ApiParam { type: 'string' | 'number' | 'boolean' | 'array' | 'object' required: boolean default?: string | number | boolean description?: string } interface ApiEndpoint { name: string path: string method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' params: Record<string, ApiParam> } type DynamicDoc = ApiEndpoint[]
И вот здесь ключевой момент.
Фронту больше не надо знать, по какому URL стучаться, каким методом и в каком виде упаковывать параметры - в query, body или path. Вся эта механика переезжает в динамическую документацию. Разработчик по-прежнему пишет имена полей в коде, но теперь это единственное, за чем нужно следить. Остальное подставляется само.
Кажется, что это путь в никуда? На самом деле нет.
Потому что «не надо знать» в данном случае значит «не зависит».
И это меняет всё.
Этим мы не просто чиним конкретную проблему. Мы выстраиваем автоматизированный слой коммуникации с бэком. Унифицированный. Предсказуемый. Который не требует ручной синхронизации.
Как это работает в коде
Перед стартом приложения мы вызываем ручку документации. Фронт загружает доку и кеширует её. Дальше обновляем раз в пять минут либо инвалидируем (сбрасываем кеш и грузим свежую версию) при 400-х ошибках.
Теперь вместо жёстко зашитого вызова:
fetch('/api/intern', { method: 'POST', body: { patience: 0, mood: 'debugging' } })
Используем:
api.call('findTheIntern', { coffeeLevel: 'empty', hope: false })
Тут api.call - это тонкая обёртка, которая подставляет URL, метод и дефолтные параметры из динамической документации. Выглядит это примерно так:
async function apiCall(endpointName: string, params: Record<string, unknown>) { const doc = await getDynamicDoc() const endpoint = doc.find(e => e.name === endpointName) if (!endpoint) throw new Error(`Unknown endpoint: ${endpointName}`) const url = buildUrl(endpoint.path, params) const body = buildBody(endpoint.params, params) return fetch(url, { method: endpoint.method, body }) }
getDynamicDoc проверяет кеш, сравнивает версию и при необходимости дёргает /api/dynamic-doc/.
Почему это не просто генерация клиента
До этого я автоматизировал генерацию типов из OpenAPI и остался доволен этим подходом (описал это в статье «Оптимизация без AI: как я автоматизировал API-ручки и типы»). Генерация клиента хороша, когда бэк стабилен и контракты не меняются каждую неделю.
Но в нашем случае бэк рефачился постоянно. Регенерировать типы после каждого спринта, следить за версиями, деплоить обновлённый пакет - всё это съедало время.
И как обычно, куда же без подводных камней
Динамическая документация - не волшебная пилюля. Вот что я быстро понял.
Производительность. Если дёргать /api/dynamic-doc/ перед каждым запросом, получишь дополнительный сетевой вызов. Я кеширую доку на несколько минут и инвалидирую при 400-х ошибках. Добавил version в ответ: если версия изменилась, фронт перезагружает доки при следующем вызове. Это сняло проблему.
Потеря типизации. Самый больной вопрос. Пришлось отказаться от статических типов для путей и параметров. Но взамен получаем гибкость. Компромисс: на уровне обёртки валидируем параметры через Zod, а схемы ответов всё ещё лежат на фронте статически.
Оверхед на бэк. Теперь бэк должен не только обрабатывать бизнес-логику, но и отдавать актуальную документацию.
Когда это стоит пробовать, а когда нет
Не буду делать вид, что этот подход универсальный. Вот мой субъективный чек-лист.
Попробовать стоит, если:
· Бэк часто меняет контракты и не всегда предупреждает.
· У вас больше пары десятков ручек и они постоянно эволюционируют.
· Команда распределённая, нет постоянной синхронизации.
Не стоит, если:
· Бэк стабилен, контракты заморожены.
· У вас 20 ручек и генерация клиента работает без сбоев.
· Синхронизация явно контролируется.
Что в итоге
Динамическая документация - это не про «забить на синхронизацию». Это про то, чтобы перенести ответственность за контракт на ту сторону, где он рождается. И про то, чтобы сделать ещё один шаг к автоматизации и унификации. Чтобы фронт не думал о том, как общаться с бэком. Чтобы этот слой работал предсказуемо и одинаково для всех ручек.
Фронт становится менее скованным изменениями на бэке. Мы тратим меньше времени на поиск неочевидных багов и больше на реальные фичи. Точных метрик я не вёл, но количество панических сообщений в чате «почему упал прод» сократилось в разы.
Хотя главное, что я вынес из этого опыта: когда ошибка не в твоём коде, ты не должен тратить часы, чтобы это понять. Архитектура должна отвечать на вопрос «где сломалось» за минуту, а не за полдня.
В погоне за абстракциями и «золотым стандартом» мы часто забываем простую истину. Твой код, твои архитектурные решения, твой рефакторинг должны помогать команде. А не доводить их до нервной трясучки.
Я не говорю, что это подойдёт всем. Это мой конкретный опыт с конкретным сервисом, а не глобальный переезд на такой подход. Но если ты тоже устал гадать, почему вдруг перестала работать ручка, которая работала вчера - возможно, тебе стоит задуматься, не пора ли твоему бэку начать говорить с фронтом на одном языке.
А как вы решаете проблему синхронизации контрактов? Используете генерацию, самописное или просто договариваетесь в чате? А может, вообще отказались от REST и ушли в GraphQL?
Комментарии (7)

artptr86
07.07.2026 18:03бэк переименовал путь ручки и одно поле в ней
Так ведь то же самое может и в вашем примере случиться: вместо
findTheInternокажетсяfindIntern, потому что кто-нибудь решит, что артикль тут излишен. Имя параметра тоже поменять могут. Как ваше Self-describing API решит эту проблему?

ZurgInq
07.07.2026 18:03Это не история про плохой бэк.
Прочитал по диагонали, но кажется всё таки про плохой бэк. И чинить надо регламентом и грамотными бэкендерами.
Помню конкретный случай. Зарелизили фичу на день позже, потому что бэк переименовал путь ручки и одно поле в ней.
Нельзя так делать.
В пиковые недели релизов у команды уходило около 25 часов не на фичи, а на вынужденную синхронизацию.
...
Попробовать стоит, если:
· Бэк часто меняет контракты и не всегда предупреждает.
Не должен бэк менять контракты, тем более без предварительного предупреждения и согласования. Проблема в регламенте. Бить по рукам.
Фронту больше не надо знать, по какому URL стучаться, каким методом и в каком виде упаковывать параметры - в query, body или path
Что бы обойти это "ограничение" и всё сломать, бэку достаточно поменять URL. А в качестве бонуса, что бы было веселее, на старый урл можно повесить новую бизнес логику.
А как вы решаете проблему синхронизации контрактов? Используете генерацию, самописное или просто договариваетесь в чате? А может, вообще отказались от REST и ушли в GraphQL?
Ответ очень простой и вроде как должен знать каждый - версионирование АПИ. Конечно никто не любит поддерживать кучу версий АПИ. Но для минимальных изменений есть простые правила: можно добавлять любые новые методы и поля, но нельзя ничего удалять или менять поведение без смены версии.
Документация в любом виде. Можно писать руками, но желательно генерировать из кода или код из документации.
В качестве стабильного протокола хорошо подходит grpc, фронты вроде как больше любят GraphQL. Но это уже всё вкусовщина.
P.S. Кто у вас там за бэк? ИИ?
savostin
Чето я не понял, ваш фронтенд просто показывает json, который пришел с бэка? Сомневаюсь. Если бэк удалил поле, заменил string на object, фронт должен обрабатывать соответственно. Следовательно фронт нужно пересобрать. В этот момент все плюшки динамической схемы улетучиваются. В минусы остаются.
grmnche Автор
Да, про структуру согласен, но в данном случае я не чинил респонсы, речь именно про запросы: параметры, пути и их получение) респонсы валидируются и меняются редко, так что динамика только там, где нужно, а по поводу ограничений со структурой респонсов как раз сделал ремарку
savostin
Да с параметрами тоже не понятно. Если бэк добавил параметр, то фронт должен его где-то взять. Да даже не могу придумать ситуации когда изменения на бэке прям автоматически подхватятся на фронте…