Ключевые моменты проектирования контрактов данных JSON API
Главное: Шесть вещей очень трудно изменить после выпуска: оболочки ответов, форматы ошибок, форма пагинации, представление ID и времени, семантика null против отсутствия и эволюция версий.

Главное: закрепите в контракте оболочку ответа, формат ошибок, представление ID и времени и семантику null-против-отсутствия, прежде чем спорить о пагинации и деталях именования; всегда оборачивайте верхний уровень в оболочку data, чтобы пагинацию и трассировочные ID можно было добавить позже, не ломая существующих вызывающих.
В дизайне API нет стандартных ответов, но есть решения, которые «трудно отменить после выбора», и их лучше продумать до написания первой строки кода.
1. Оборачивать ответ или нет
Три стиля:
// Стиль A: голый ответ
{ "id": 1, "name": "张三" }
// Стиль B: оболочка data
{ "data": { "id": 1, "name": "张三" } }
// Стиль C: data + метаданные
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Стиль | Плюсы | Минусы |
|---|---|---|
| Голый ответ | Проще всего, на один уровень меньше для фронтенда | Нельзя прикрепить метаданные; массив верхнего уровня несёт риски перехвата JSON и эволюции |
| Оболочка data | Легко добавить meta / errors / links |
Ещё один уровень вложенности, фронтенд должен писать res.data.data |
| data + метаданные | Лучшая наблюдаемость | Требуется строгое соглашение о том, какие поля принадлежат meta |
Рекомендация: используйте оболочку data единообразно и для списков, и для деталей, и никогда не делайте верхний уровень массивом. Так добавление пагинации или трассировочных ID позже никогда не ломает контракт.
2. Формат ошибок: копируйте RFC 9457
Изобретение собственной структуры ошибок — огромный источник стоимости сотрудничества. RFC 9457 (заменяет RFC 7807) определяет application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Поле | Значение | Обязательное |
|---|---|---|
type |
Стабильный URI типа проблемы | Рекомендуется |
title |
Краткая человекочитаемая сводка | Рекомендуется |
status |
HTTP-код статуса | Рекомендуется |
detail |
Конкретное объяснение того, что произошло в этот раз | Необязательное |
instance |
Экземпляр ресурса, с которым возникла проблема | Необязательное |
| Поля расширения | Произвольные бизнес-поля | Необязательные |
Поля расширения (например balance / required выше) разрешены спецификацией, и фронтенд может использовать их для точных сообщений вместо парсинга текста detail.
3. Пагинация: курсор лучше offset
// Пагинация по offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Пагинация по курсору
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Аспект | Пагинация по offset | Пагинация по курсору |
|---|---|---|
| Сложность реализации | Низкая (LIMIT/OFFSET) |
Средняя (нужен стабильный ключ сортировки) |
| Производительность на глубоких страницах | Плохая (чем больше offset, тем медленнее) | Хорошая |
| Когда данные меняются | Дубликаты или пропущенные элементы | Стабильно |
| Переход на страницу | Возможен | Невозможен |
| Нужен общий счётчик | Требует дополнительного подсчёта | Не требуется |
Рекомендация: используйте пагинацию по offset для административных таблиц (им нужны переходы по страницам и итоги), а пагинацию по курсору — для бесконечных лент, обращённых к пользователю.
4. ID и время
4.1 Всегда используйте строки для ID
Обоснование рассмотрено в статье о типах: на стороне JavaScript значения выше 2^53 молча теряют точность.
Даже если текущий ID — маленькое автоинкрементное целое, использование строк оставляет место для перехода на snowflake ID позже.
{ "id": "1000000000000000001" }
4.2 Используйте строки RFC 3339 для времени
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- Не используйте числовые метки времени в миллисекундах: они трудночитаемы, и единицу (секунды/миллисекунды) часто путают;
- Указывайте часовой пояс явно: либо с
Z, либо со смещением; никогда не используйте «локальное время без часового пояса»; - Именуйте поля единообразно как
xxxAt, чтобы с первого взгляда было очевидно, что это точка во времени.
5. null и отсутствие
Вы должны выбрать одно и записать это в документацию:
| Подход | Выражение «нет значения» | Выражение «не изменять» |
|---|---|---|
| Подход A | Значение поля равно null |
Опустить поле в запросе |
| Подход B | Поля не существует | Опустить поле в запросе |
Проблема подхода A в том, что «опущено» и «отправлено как null» означают разное в PATCH, что легко неверно истолковать;
проблема подхода B в том, что JSON.stringify во фронтенде отбрасывает поля со значением undefined, что случайно совпадает с семантикой.
Практическая рекомендация:
- Используйте подход A для тел ответов (
nullясно означает отсутствие значения, и фронтенду не нужно различатьundefined); - Используйте подход B для тел запросов (особенно
PATCH), в сочетании с Merge Patch или JSON Patch, чтобы сделать семантику явной.
6. Именование и структура полей
| Решение | Частые варианты | Оговорка |
|---|---|---|
| Стиль именования | camelCase (экосистема JS) / snake_case (Python, базы данных) |
Единообразно по проекту, не смешивать |
| Значения перечислений | Строчные с подчёркиваниями, "pending_review" |
Не используйте числа; они трудночитаемы |
| Булевы поля | isActive / enabled |
Не используйте status: 0/1 |
| Поля массивов | Существительные во множественном числе, items |
Возвращайте [] для пустых массивов, никогда null |
| Глубина вложенности | Не более 3 уровней | Глубже — разделите или предложите параметр expand |
Возврат [] вместо null для пустых массивов важен: data.items.map(...) во фронтенде не упадёт из-за null.
7. Стратегия эволюции
| Изменение | Ломает совместимость | Как обрабатывать |
|---|---|---|
| Добавить необязательное поле | Нет | Выпускайте напрямую |
| Добавить обязательное поле | Да | Добавьте значение по умолчанию или выпускайте по версии |
| Удалить поле | Да | Сначала пометьте устаревшим, наблюдайте за вызывающими, затем удалите |
| Изменить тип поля | Да | Добавьте новое имя поля и двойную запись во время перехода |
| Изменить набор значений перечисления | Зависит | Клиент должен иметь запасной вариант для неизвестных значений |
| Изменить семантику поля | Да | Трактуйте как новое поле |
Три правила дисциплины:
- Клиенты должны быть терпимы к неизвестным полям (не загоняйте себя в угол через
additionalProperties: false); - Клиенты должны иметь запасную ветку для неизвестных значений перечислений;
- Держите устаревшие поля как минимум одну мажорную версию и помечайте их
Deprecationв заголовках ответа или документации.
8. Полный пример
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Этот ответ отражает все соглашения выше: оболочка data, строковые ID, суммы как строки, метки времени с часовым поясом,
null означает отсутствие, пустой массив, пагинация по курсору и meta для информации наблюдаемости.
9. Контрольный список
- [ ] Верхний уровень не массив; используйте оболочку
dataединообразно; - [ ] Ответы об ошибках следуют RFC 9457 и включают стабильный
type; - [ ] Форма пагинации соответствует бизнес-сценарию;
- [ ] ID — строки, время использует RFC 3339;
- [ ] Семантика
nullи отсутствия записана в документацию; - [ ] Стиль именования единообразен по проекту, перечисления используют строчные строки;
- [ ] Клиент имеет запасные варианты для неизвестных полей и неизвестных значений перечислений;
- [ ] Ломающие изменения проходят через версионирование или переход с двойной записью.
Вопросы и ответы
Как проектировать эволюционируемый JSON API?
Оборачивайте списки и детали единообразно в оболочку data и никогда не делайте верхний уровень массивом, чтобы пагинацию и трассировочные ID можно было добавить, не ломая контракт. Используйте строковые ID и метки времени RFC 3339 и запишите семантику null-против-отсутствия в документацию. Держите новые поля необязательными и семантически независимыми и давайте клиентам запасные варианты для неизвестных полей и значений перечислений.
Почему не изобретать собственный формат ошибок?
Изобретение собственной структуры ошибок — огромный источник стоимости сотрудничества. Копируйте application/problem+json по RFC 9457, идентифицируйте проблемы стабильным type и допускайте поля расширения вроде balance и required, чтобы фронтенд мог строить точные сообщения без парсинга текста detail.
Должна ли каждая конечная точка использовать пагинацию по курсору?
Нет. Административным таблицам нужны переходы по страницам и итоги, поэтому пагинация по offset подходит лучше; бесконечные ленты, обращённые к пользователю, должны использовать пагинацию по курсору, которая остаётся стабильной на глубоких страницах и избегает дубликатов или пробелов при изменении данных. Выбирайте по тому, нужны ли сценарию переходы по страницам и итоги.