Каждый туториал учит одному и тому же REST API: пять роутов, happy path, JSON на входе, JSON на выходе, готово за вечер. Потом API встречается с реальностью — мобильный клиент, который три недели не может обновиться, партнёрская интеграция, написанная поверх опечатки, которую теперь нельзя исправить, security-ревью, баг пагинации, который никого не списал дважды, но всем всё показал дважды, — и вы обнаруживаете, что туториал покрыл лёгкие 20%. Про остальные 80% и написан этот пост.
Это решения, которые мне приходилось защищать (или о которых жалеть) за годы production-API, — в том порядке, в каком они обычно кусают. И не случайно это ровно те темы, которые прощупывает system design интервью, когда интервьюер говорит: «а теперь спроектируйте для этого API».
Как версионировать API, который нельзя ломать?
Версионируйте с первого дня, в URL, и относитесь к каждому опубликованному полю как к контракту, который будете соблюдать годами. В момент, когда один консьюмер, которого вы не контролируете, выкатывает код поверх вашего API, «потом почистим» превращается в фикцию: переименовать поле — breaking change, удалить поле — breaking change, поменять тип — это инцидент с вашей фамилией. /v1/ в пути негламурен и слегка неэлегантен — и это подход, у которого скучные режимы отказа, а это высший комплимент в дизайне API.
Прежде чем идти решение за решением, вот весь пост одной таблицей — туториальный ответ, ответ, который переживает пять лет консьюмеров, и то, во что реально обходится срезанный угол, когда приходит счёт:
| Решение | Туториальный ответ | Ответ, который выживает | Во что обходится срезанный угол |
|---|---|---|---|
| Версионирование | Добавляйте и переименовывайте поля свободно | /v1/ с первого дня; после публикации — только аддитивные изменения | Каждое переименование — инцидент внутри чужого приложения |
| Auth по умолчанию | Добавьте auth на те endpoint’ы, которым он нужен | Deny by default; публичные роуты помечены явно | Тот единственный endpoint, который кто-то забыл, — краулер найдёт его раньше вас |
| Авторизация | Валидный токен означает, что вызывающему можно | Проверяйте владение при каждом чтении ресурса | Данные вашего клиента, отданные другому клиенту |
| Ошибки | HTTP-статус плюс сообщение прозой | Машинный код, человеческое сообщение, correlation ID | Интеграторы гадают; их баг-репорт становится вашим звонком в 3 часа ночи |
| Пагинация | ?page=3 | Непрозрачный cursor со смыслом «продолжай после этой строки» | Элементы дублируются и исчезают под записями; глубокие страницы сканируют и выбрасывают |
| Аудитория документации | Люди, которые её пролистают | Самый буквальный консьюмер, который у вас когда-либо будет | Агент галлюцинирует параметр, который вы оставили расплывчатым |
Настоящая дисциплина не в схеме URL, а в привычке к аддитивным изменениям. Новая возможность? Новое опциональное поле, новый endpoint — и никогда не изменённый смысл существующего. Команды, усвоившие правило «опубликовано — значит навсегда», проектируют поля внимательнее до публикации, и в этом и есть реальная польза: дисциплина версионирования — по большей части forcing function, заставляющая подумать дважды.
Как выглядит auth, который «fails closed»?
Каждый endpoint требует аутентификации, если он явно и осознанно не помечен как публичный — паттерн deny-by-default middleware, где забытая конфигурация роута означает, что он закрыт, а не открыт. Повторяющаяся катастрофа индустрии в API — не сломанная криптография, а endpoint, который кто-то забыл защитить и который нашёл краулер. Fail-closed дизайн делает такую ошибку структурно невозможной: ленивый путь и безопасный путь — один и тот же путь.
Авторизация заслуживает той же паранойи уровнем глубже: аутентификация говорит, кто вы, авторизация — что ваше, и зазор между ними — классическая утечка. GET /orders/12345 с валидным токеном всё равно обязан проверить, что заказ 12345 принадлежит пользователю этого токена — баг отсутствующей проверки владения (IDOR на языке безопасности) остаётся самой частой серьёзной уязвимостью API на код-ревью, и сгенерированный код воспроизводит его с энтузиазмом, потому что happy-path-версия выглядит идентично.
Почему ответы об ошибках заслуживают времени на дизайн?
Потому что ваши ошибки — тоже API, тот самый, который в 3 часа ночи потребляет уставший интегратор, решая, чей это баг — его или ваш. Живучий ответ об ошибке несёт три вещи: машиночитаемый код (insufficient_funds, а не просто HTTP 400), человекочитаемое сообщение с именем проблемного поля и correlation ID, по которому ваши логи и его баг-репорт находят друг друга. Одного HTTP-статуса слишком мало: 400, означающий пять разных вещей, заставляет каждого консьюмера парсить вашу прозу.
Предсказуемость — фича. Сюрприз — издержка. API, которые называют «приятными в интеграции», редко бывают хитрыми — они делают одно и то же везде.
Консистентность важнее богатства. Один error envelope, идентичная форма на каждом endpoint, задокументировано один раз. API, которые описывают как «приятные в интеграции», редко делают что-то хитрое — они делают одно и то же предсказуемое везде, поэтому обработка ошибок у интегратора — одна функция, а не по одной на endpoint. Предсказуемость — фича; сюрприз — издержка, тот же урок, который durability преподаёт на уровне архитектуры.
Что не так с пагинацией по номерам страниц?
Offset-пагинация (?page=3) врёт при конкурентных записях: строки, вставленные или удалённые, пока консьюмер идёт по страницам, заставляют элементы появляться дважды или исчезать, а глубокие offset-ы наказывают базу полным scan-and-discard. Она выживает в туториалах, потому что тривиальна и выглядит правильно в демо со статичными данными. Cursor-пагинация — непрозрачный токен со смыслом «продолжай после этой строки» — остаётся корректной под записями и быстрой на глубине, ценой потери «перейти на страницу 7», которая большинству реальных консьюмеров никогда не была нужна.
Переносимое правило: пагинируйте по позиции в упорядочении, а не по счёту от начала, всякий раз, когда данные меняются под читателями. Это маленькое дизайн-решение с длинным хвостом: прикручивать cursor-ы к уже отгруженному offset-API — ровно та миграция опубликованного контракта, о которой предупреждала секция про версионирование.
Меняется ли что-то, когда консьюмер — AI-агент?
Принципы выживают; допуски ужесточаются. Агенты потребляют API через tool definitions, и всё перечисленное выше становится машинно-ориентированным: расплывчатое сообщение об ошибке, мимо которого человек пожал бы плечами, отправляет агента в retry-цикл, неконсистентный envelope ломает его парсинг, недодокументированный параметр — галлюцинируется. Проектировать API, которыми агенты могут пользоваться надёжно, — точные schema, исчерпывающие коды ошибок, честные описания — то же ремесло, что хороший REST-дизайн, только без скидки на небрежность. В этом тезис объяснения MCP: интерфейсы инструментов — это дизайн API, второй раунд, с менее снисходительным консьюмером.
Вот почему путь по фундаментальным курсам по-прежнему проходит через построение реальных REST-сервисов — Node.js и production-API ради самого ремесла, на этаже backend-архитектуры, где решается, что сервис, а что handler, с TypeScript как дисциплиной типизации, которая делает контракты принудительными, а не декларативными. У каждого из этих навыков теперь есть второй заказчик: агент, вызывающий ваш API через schema, — ровно такой же буквальный, как тайпчекер, и заметно более дорогой, когда сбит с толку.
Чек-лист выживания
Версия в URL с первого дня; после публикации — только аддитивные изменения. Deny-by-default auth; проверка владения на каждом чтении ресурса. Один error envelope везде: машинный код, человеческое сообщение, correlation ID. Cursor-ы, а не offset-ы, всюду, где данные движутся. Документируйте для самого буквального консьюмера, который у вас когда-либо будет, — потому что с этого года он у вас есть. Ничто из этого не гламурно. Как и мост, который не падает.