Ключові моменти проєктування контрактів даних 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. Пагінація: курсор краще за зміщення
// Пагінація зі зміщенням
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Пагінація за курсором
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Вимір | Пагінація зі зміщенням | Пагінація за курсором |
|---|---|---|
| Складність реалізації | Низька (LIMIT/OFFSET) |
Середня (потрібен стабільний ключ сортування) |
| Продуктивність на глибоких сторінках | Погана (що більше зміщення, то повільніше) | Добра |
| Коли дані змінюються | Дублікати або пропущені елементи | Стабільно |
| Перехід на сторінку | Можливий | Неможливий |
| Потрібна загальна кількість | Потребує додаткового підрахунку | Не потрібна |
Рекомендація: використовуйте пагінацію зі зміщенням для адмінських таблиць (їм потрібні переходи на сторінки й підсумки), а пагінацію за курсором — для нескінченних стрічок для користувачів.
4. ID і час
4.1 Завжди використовуйте рядки для ID
Обґрунтування розглянуто в статті про типи: на боці JavaScript значення понад 2^53 мовчки втрачають точність.
Навіть якщо поточний ID — маленьке автоінкрементне ціле, використання рядків залишає місце для переходу на 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.
Чи має кожна кінцева точка використовувати пагінацію за курсором?
Ні. Адмінські таблиці потребують переходів на сторінки й підсумків, тож пагінація зі зміщенням підходить краще; нескінченні стрічки для користувачів мають використовувати пагінацію за курсором, яка стабільна на глибоких сторінках і уникає дублікатів чи прогалин, коли дані змінюються. Обирайте залежно від того, чи сценарій потребує переходів на сторінки й підсумків.