
Когда в последний раз ты искал баг, который оказался не твоей ошибкой? А сколько раз ты делал это сегодня?
Я про те случаи, когда всё падает, консоль красная, ты перепроверяешь код в десятый раз и не понимаешь, где накосячил. А потом выясняется: бэк просто поменял контракт. Без предупреждения. Без обновлённого свагера. Просто «бесшумный рефакторинг».
Вот только рефачил не ты. А гадать, почему прилетает user_name, хотя ещё вчера было username, приходится тебе.
Это не история про плохой бэк. Это история про то, как устроены процессы в командах, где больше нескольких разработчиков, которые не сидят в одном чате 24/7. Руководители не держат в голове каждое минорное изменение. Бэк не всегда синхронизируется с фронтом. Документация либо отсутствует, либо устарела.
Помню конкретный случай. Зарелизили фичу на день позже, потому что бэк переименовал userId в userUid. Фронт честно слал userId, бэк честно его игнорировал и подставлял дефолтное значение. Никакой ошибки. Просто данные молча сохранялись не в ту учётку. Полдня тестировщики гадали, почему фича работает не так, как задумано. Ещё полдня я перекапывал свой код, прежде чем полез в контракт и увидел, что поле теперь называется по-другому.
Несколько месяцев я работал с одним сервисом в такой парадигме. Искал несоответствия в контрактах, сверял пути, переписывал типы. В пиковые недели релизов у команды уходило около 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[]
И вот здесь ключевой момент.
Фронту больше не надо знать, по какому урлу стучаться. Не надо знать, какие параметры передавать.
Кажется, что это путь в никуда? На самом деле нет.
Потому что «не надо знать» в данном случае значит «не зависит».
И это меняет всё.
Этим мы не просто чиним конкретную проблему. Мы выстраиваем автоматизированный слой коммуникации с бэком. Унифицированный. Предсказуемый. Который не требует ручной синхронизации.
Как это работает в коде
Перед стартом приложения мы вызываем ручку документации. Фронт загружает доку и кеширует её. Дальше обновляем раз в пять минут либо инвалидируем (принудительно сбрасываем кеш и грузим свежую версию) при 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-ручки и типы»). Генерация клиента хороша, когда бэк стабилен и контракты не меняются каждую неделю.
Но в нашем случае бэк рефачился постоянно. Регенерировать типы после каждого спринта, следить за версиями, деплоить обновлённый пакет — всё это съедало время.
В отличие от статической генерации, у нас нет файла с типами, который лежит в проекте и требует обновления при каждом чихе на бэке. Бэк может поменять поле value на new_value. Мы просто ничего не делаем. Фронт подхватывает изменения из доки и передаёт актуальные поля. Переписывать код не нужно. Типы не нужно править. Шаблоны не нужно трогать.
Но здесь есть тонкость. Динамическая документация решает проблему с параметрами запроса. А что со структурой ответа?
Я пробовал передавать тип возвращаемых данных отдельным полем:
{ "responseStructure": "arr", "responseType": "Intern", "responseSchema": { "id": "string", "name": "string", "coffeeLevel": "string", "hope": "boolean", "mood": "string" }}
На фронте можно валидировать ответы через Zod (библиотека для проверки типов в рантайме, чтобы отловить невалидные данные до того, как они упадут в продакшене) на основе этого респонса. Но я бы не рекомендовал уходить в эту историю без чёткого понимания, зачем.
Да, можно полностью перевести фронт на рельсы управляемости бэком. Но тогда ты теряешь типизацию (твой редактор больше не подскажет, какие поля есть у объекта, и не подсветит ошибку до запуска). Теряешь статический анализ. Начинаешь ловить ошибки только в рантайме. Для прототипов это ок. Для продакшена — надо думать.
И как обычно, куда же без подводных камней
Динамическая документация — не волшебная пилюля. Вот что я быстро понял.
Производительность. Если дёргать /api/dynamic-doc/ перед каждым запросом, получишь дополнительный сетевой вызов. Я кеширую доку на пять минут и инвалидирую при 400-х ошибках. Добавил version в ответ: если версия изменилась, фронт перезагружает доки при следующем вызове. Это сняло проблему.
Потеря типизации. Самый больной вопрос. Пришлось отказаться от статических типов для путей и параметров. Но взамен получаем гибкость. Компромисс: на уровне обёртки валидируем параметры через Zod, а схемы ответов всё ещё лежат на фронте статически. Да, это возвращает проблему синхронизации структуры ответа. Но структуру меняют реже, чем пути и параметры. Пока такой гибрид меня устраивает.
Оверхед на бэк. Теперь бэк должен не только обрабатывать бизнес-логику, но и отдавать актуальную документацию.
Когда это стоит пробовать, а когда нет
Не буду делать вид, что этот подход универсальный. Вот мой субъективный чек-лист.
Попробовать стоит, если:
· Бэк часто меняет контракты и не всегда предупреждает.
· У вас больше пары десятков ручек и они постоянно эволюционируют.
· Команда распределённая, нет постоянной синхронизации.
Не стоит, если:
· Бэк стабилен, контракты заморожены.
· У вас 20 ручек и генерация клиента работает без сбоев.
· Синхронизация явно контролируется.
Что в итоге
Динамическая документация — это не про «забить на синхронизацию». Это про то, чтобы перенести ответственность за контракт на ту сторону, где он рождается. И про то, чтобы сделать ещё один шаг к автоматизации и унификации. Чтобы фронт не думал о том, как общаться с бэком. Чтобы этот слой работал предсказуемо и одинаково для всех ручек.
Фронт становится менее скованным изменениями на бэке. Мы тратим меньше времени на поиск неочевидных багов и больше на реальные фичи. Точных метрик я не вёл, но количество панических сообщений в чате «почему упал прод» сократилось в разы.
Хотя главное, что я вынес из этого опыта: в погоне за идеальной архитектурой, абстракциями и «золотым стандартом» мы часто забываем простую истину. Твой код, твои архитектурные решения, твой рефакторинг должны помогать команде. А не доводить их до нервной трясучки.
Да, у нас есть компромиссы. Да, мы потеряли статическую типизацию для путей. Но мы выиграли в скорости разработки и снизили количество багов, связанных с несоответствием контрактов.
Я не говорю, что это подойдёт всем. Это мой конкретный опыт с конкретным сервисом, а не глобальный переезд на такой подход. Но если ты тоже устал гадать, почему вдруг перестала работать ручка, которая работала вчера — возможно, тебе стоит задуматься, не пора ли твоему бэку начать говорить с фронтом на одном языке.
А как вы решаете проблему синхронизации контрактов? Используете генерацию, самописное или просто договариваетесь в чате? А может, вообще отказались от REST и ушли в GraphQL?
ссылка на оригинал статьи https://habr.com/ru/articles/1056692/