1. Дрібниці JSON як правила контракту
Коли ви пишете API, легко потрапити в пастку: «головне, щоб кінцева точка працювала, а там JSON якось». Але клієнту «якось» не підходить. Клієнту потрібно розуміти, що означає кожне значення: status = "DONE" — це факт, tags = [] — це теж факт, а ось description = null — уже тонка семантика. Ці деталі перетворюються на домовленості, які потім важко змінювати.
Уявіть, що ваш API — це не просто «відповідь сервера», а інструкція з експлуатації для клієнта. І в цій інструкції ви пишете: «кнопка може бути, а може й не бути; якщо її немає — це нормально; якщо вона є, але порожня — це теж нормально». Клієнт починає писати код із перевірками на null, порожнечу й «а раптом поля немає». У підсумку дрібниці стають або джерелом багів, або причиною, через яку клієнтська команда тихо вас ненавидить — а потім уже й голосно.
У цій лекції ми розберемо п’ять «дрібниць», які на практиці виявляються дуже великими: enum, boolean, null, порожні колекції та відсутні поля. І головне — навчимося закріплювати їхній зміст, щоб API було передбачуваним.
Для стислості нижче кілька DTO подано як скорочені фрагменти з public fields. Це не новий стиль на рівні всього проєкту і не канонічний вигляд файлів проєкту; так просто легше зосередитися на значенні JSON-полів, а не на службовій обв’язці.
2. enum як словник API
З enum зазвичай усе виглядає мило: у Java це акуратний список допустимих значень, у JSON це рядок. Але саме через цю «простоту» люди часто розслабляються і починають змінювати enum так, ніби це внутрішній код, який ніхто не бачить. А він видимий — він виступає назовні як частина контракту. Перейменували IN_PROGRESS на INWORK — і раптом поламали клієнтів, які чесно парсили рядок.
У нашому Task Tracker API enum — це публічний словник. Він описує стани й пріоритети так, щоб клієнт міг на них спиратися. І тут корисно мислити так: значення enum — це майже як назва кінцевої точки. Вони живуть довго, і змінювати їх «заради забаганки» не можна.
Почнемо з базового: як enum виглядає в проєкті.
package com.example.tasktracker.domain.model;
public enum TaskStatus {
// Важливо: ці рядки підуть у JSON як є (наприклад, "TODO", "DONE").
// Тому перейменування тут = зміна публічного контракту API.
TODO,
IN_PROGRESS,
BLOCKED,
DONE,
ARCHIVED
}
І другий словник:
package com.example.tasktracker.domain.model;
public enum TaskPriority {
// Абсолютно та сама історія: значення живуть у контракті та мають бути стабільними.
LOW,
MEDIUM,
HIGH,
CRITICAL
}
Якщо ви віддаєте TaskStatus назовні в DTO, Jackson за замовчуванням серіалізує enum як рядок з іменем константи. Тобто TaskStatus.IN_PROGRESS стане "IN_PROGRESS".
Приклад DTO:
package com.example.tasktracker.api.dto.response;
import com.example.tasktracker.domain.model.TaskStatus;
public class TaskSummaryResponse {
// Скорочений фрагмент response DTO: public fields тут лише заради компактності прикладу.
public String id;
// Заголовок задачі для людини
public String title;
// Статус віддається як рядок зі значенням enum (наприклад, "TODO")
public TaskStatus status;
}
Якщо контролер поверне такий DTO, клієнт побачить приблизно це:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Написати документацію",
"status": "TODO"
}
Тут є важлива домовленість: клієнт має надсилати й очікувати рівно ці рядки. Зазвичай це означає, що значення enum в API пишуть в одному стилі (часто UPPER_CASE), і цей стиль стає частиною контракту.
Щоб відчути проблему, погляньмо на маленький негативний сценарій. Припустімо, клієнт надіслав "in_progress" замість "IN_PROGRESS". Для Jackson це, як правило, інше значення, і він не зможе «вгадати». У підсумку запит не дійде до сервісного шару — зламається на стадії десеріалізації.
Наприклад, такий JSON (для майбутнього запиту оновлення) буде проблемним:
{
"status": "in_progress"
}
І це нормально. API не зобов’язаний вгадувати за клієнта. Але тоді ваше завдання як розробника API — зробити так, щоб правильні значення були очевидні: у прикладах, документації та стабільному контракті.
Ще одна думка, яка дуже допомагає: значення enum — це не текст для інтерфейсу. Не треба робити DONE → "Виконано" в API. Текст для UI — це робота клієнта (або окремого шару локалізації), а API має бути орієнтованим на машинну обробку.
3. Boolean-прапорці в JSON
Булевий тип здається найпростішим типом у світі: true або false, що може піти не так? На практиці — багато чого. По-перше, boolean-поля часто називають так, що їх неможливо читати без шапочки з фольги: isOk, flag, state. По-друге, boolean люблять завертати в рядки ("yes", "no") і потім страждати. По-третє, boolean інколи роблять опціональним, але залишають примітив boolean, і втрачають можливість відрізнити «не передали» від «передали false».
У Task Tracker API у нас є чудовий кандидат на такий прапорець: archived. За ТЗ проєкту архівність може бути зручною похідною ознакою, але джерелом істини залишається status. Це гарна ілюстрація того, як boolean може поліпшити читабельність API — якщо домовитися, що він означає.
Давайте уявимо response DTO «деталі задачі» та додамо туди прапорець архівності:
package com.example.tasktracker.api.dto.response;
import com.example.tasktracker.domain.model.TaskStatus;
import java.util.List;
public class TaskDetailsResponse {
// Скорочений фрагмент response DTO: тут уже видно поля, які знадобляться
// для boolean і для розмови про порожні колекції; інші поля опущено.
public String id;
public String title;
// Джерело істини: статус задачі
public TaskStatus status;
// Зручний для клієнта прапорець: завжди true/false (без null)
public boolean archived;
// Колекції далі теж знадобляться: у відповіді для клієнта їх краще тримати передбачуваними
public List<String> tags;
}
Ззовні це виглядатиме так:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Написати документацію",
"status": "ARCHIVED",
"archived": true
}
І ось тут ми робимо важливу домовленість: поле archived читається як проста властивість, а не як «прапорець заради прапорця». Хороше boolean-поле зазвичай відповідає на запитання «Це правда, що…?»:
- archived: true — це правда, що задача в архіві.
- archived: false — це правда, що задача не в архіві.
Якби поле називалося, наприклад, taskArchiveState, то це вже не boolean, а «загадка».
Тепер — невеликий, але критичний нюанс Java-типу. У response DTO boolean зазвичай доречний: у відповіді ми хочемо завжди віддавати або true, або false. Клієнту зручно, він не робить тривіальних перевірок.
А ось для request DTO (входу), якщо поле опціональне, примітивний boolean — потенційна пастка. Бо якщо клієнт не передав поле взагалі, Jackson поставить значення за замовчуванням, і ви отримаєте false, хоча клієнт «нічого не казав».
Міні-демонстрація, чому це небезпечно:
package com.example.tasktracker.api.dto.request;
public class TaskSearchCriteria {
// Boolean (а не boolean), щоб розрізняти:
// - null: фільтр не задано (не фільтруємо за архівністю)
// - true/false: фільтр задано явно
public Boolean archived;
}
Якщо archived — Boolean, то відсутність поля/параметра можна інтерпретувати як «не фільтрувати за архівністю», а true/false — як явний фільтр. Для контракту це часто зрозуміліше.
До речі, boolean у JSON має бути boolean. Не рядком "true", не числом 1, не словом "yes". Це не релігія, це просто економія нервів усім сторонам.
4. null, [] і відсутність поля
Зараз буде фрагмент «контрактної філософії», але без неї не можна. У JSON є три зовні схожі ситуації: поле є і null, поле є і порожня колекція, поля немає взагалі. Новачки часто сприймають це як «ну немає даних, і все». Клієнти — ні. Для клієнта це три різні сигнали, і якщо ви їх змішуєте, ви ламаєте передбачуваність API.
Найзручніше — одразу тримати в голові таку таблицю смислів:
| Ситуація | Як виглядає JSON | Як це зазвичай читається клієнтом | Типова реакція клієнта |
|---|---|---|---|
| Явна відсутність значення | "description": null | Поле існує в контракті, але зараз значення немає | Показує «немає опису» |
| Порожня колекція | "tags": [] | Поле існує, елементи просто закінчилися | Спокійно відображає порожній список |
| Поля немає | {} (ключа немає) | Або поле не входить до контракту, або клієнт і сервер на різних версіях | Клієнт починає здогадуватися, що це означає |
Тепер важлива частина: як це мапиться в Java, якщо ви використовуєте звичайні DTO.
Відсутність поля → null
Для посилальних типів (наприклад, String, List<String>, LocalDate) під час десеріалізації зазвичай стається так: якщо поля немає, значення буде null.
Наприклад, request DTO:
package com.example.tasktracker.api.dto.request;
import java.util.List;
public class TaskCreateRequest {
// Обов’язкове поле (за контрактом), але технічно може прийти null — це треба валідувати окремо.
public String title;
// Може бути відсутнім або бути null: це нормальний стан «опису немає».
public String description;
// Важливо: якщо поле не прийшло, буде null. Якщо прийшло як [], буде порожній список.
public List<String> tags;
}
Якщо клієнт надіслав:
{
"title": "Написати документацію"
}
то description і tags у Java будуть null. А якщо клієнт надіслав:
{
"title": "Написати документацію",
"tags": []
}
то tags буде порожнім списком, а не null.
Це чудовий приклад того, чому null і [] не можна плутати. Вони призводять до різної логіки навіть на сервері. І на сервері має бути зрозуміла домовленість: «якщо tags не прийшли — вважаємо, що тегів немає» або «якщо tags не прийшли — вважаємо, що клієнт не хотів їх передавати» (у create-сценаріях частіше перше).
Порожня колекція замість null
Порожня колекція — це «є контейнер, просто він порожній». Для клієнта це зручніше, тому що код виходить простим: можна завжди робити цикл по tags, не перевіряючи null.
Порівняймо два варіанти.
Варіант № 1, незручний:
{ "tags": null }
Клієнт (і сервер) змушені писати: «якщо tags != null, тоді…».
Варіант № 2, спокійний:
{ "tags": [] }
Клієнт може просто відобразити список — він буде порожнім, і все.
Тому в response DTO дуже часто обирають правило: колекції не мають бути null. Навіть якщо елементів немає — це порожній список.
І тут увага: це не про «красу», а про частину контракту. Якщо сьогодні ви віддавали tags: [], а завтра почали віддавати tags: null, частина клієнтів може просто впасти, тому що вони розраховували на масив.
Відсутність поля vs null
Відсутнє поле — це сильний сигнал. Іноді він означає: «це поле взагалі не входить до контракту» або «ми його не підтримуємо». Іноді — «ми його прибрали» або «сервер старіший». Іноді — «ми не хочемо показувати це поле через певні правила». І ось через це відсутнє поле часто робить контракт менш прозорим, якщо воно з’являється «випадково».
На рівні сьогоднішньої лекції важливо запам’ятати: відсутнє поле і null — різні сигнали, і якщо ви хочете, щоб поле існувало в контракті, то логічніше віддавати його явно (навіть із null), ніж «то є, то немає».
Як саме керувати тим, показувати null чи приховувати поле, — це вже окреме налаштування і окремі інструменти. Сьогодні ми просто фіксуємо зміст і акуратність: спершу домовленість, потім техніка.
5. Домовленості Task Tracker API
Щоб теорія не повисла в повітрі, нам потрібно зробити те, що відрізняє «проєкт» від «набору прикладів»: зафіксувати домовленості для конкретних полів Task Tracker API. Ідея проста: клієнт має заздалегідь знати, що робити з tags, що робити з description, як трактувати archived, і які рядки він побачить у status та priority. Це не про «ідеально» — це про «послідовно».
Нижче — зручна «пам’ятка договору» саме для задач. Вона не замінює документацію, але допомагає вам як розробнику тримати модель у голові й писати мапінг без сюрпризів.
| Поле в TaskDetailsResponse | Тип | Що ми хочемо бачити в JSON | Чому так зручніше |
|---|---|---|---|
| status | TaskStatus | Рядок із фіксованого набору ("TODO", "DONE", …) | enum — стабільний словник, на нього можна писати логіку клієнта |
| priority | TaskPriority | Рядок із фіксованого набору | З тих самих причин: передбачуваність і відсутність «магічних чисел» |
| archived | boolean | Завжди є і завжди true/false | Клієнту не потрібні null-перевірки; прапорець читається однозначно |
| tags | List<String> | Завжди є, мінімум [] | Клієнту зручно ітеруватися; null ускладнює життя без користі |
| description | String | Поле може бути null | «Опису немає» — валідний стан; це чесно відображається null |
| assigneeName | String | Поле може бути null | Призначений виконавець може бути відсутнім, і це нормальний випадок |
| dueDate | LocalDate | Поле може бути null або ISO-рядок дати | Дедлайн може бути не заданий; якщо заданий — читабельний формат |
Зверніть увагу: ми тут не говоримо, що «завжди треба саме так». Ми говоримо: у нашому проєкті так домовилися. Контракт важливіший за смаковщину. Якщо ви обрали правило «колекції не null», значить ви маєте забезпечити це в мапінгу й seed data, інакше правило залишиться гарним текстом, а не реальністю.
6. DTO і mapper: детермінований JSON
Теорія про null і порожні колекції чудова, але продакшен починається там, де ви не сподіваєтеся, що «десь там tags не буде null», а робите так, щоб tags справді ніколи не був null у відповіді. Це як із ременем безпеки: можна сподіватися на охайних водіїв навколо, а можна пристебнутися.
Зробімо маленький рефакторинг у стилі нашого проєкту: усе на рівні DTO + mapper, без магії та без глобальних налаштувань.
Нехай внутрішня модель Task зберігає теги як Set<String> (або List<String> — неважливо). Головне — у DTO ми хочемо List<String> і хочемо гарантувати, що цей список не null.
Спрощена внутрішня модель:
package com.example.tasktracker.domain.model;
import java.util.Set;
public class Task {
// У лекції модель спрощено: показуємо лише поля, важливі для прикладу.
private String id;
// Теги можуть бути null на рівні доменної моделі — і саме це ми хочемо "нормалізувати" в DTO.
private Set<String> tags;
public String getId() { return id; }
public Set<String> getTags() { return tags; }
// Примітка: у реальному класі будуть і інші поля/гетери (наприклад, статус),
// але тут вони не потрібні для цього фрагмента.
}
Нижче — уже фрагмент мапінгу до цього ж DTO: зараз важливі нормалізація tags і обчислення archived, а присвоєння інших полів свідомо опущено.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
import java.util.List;
public class TaskMapper {
public TaskDetailsResponse toDetailsResponse(Task task) {
TaskDetailsResponse dto = new TaskDetailsResponse();
// Переносимо ідентифікатор 1-в-1
dto.id = task.getId();
// Важливо для контракту:
// якщо task.getTags() == null, то в JSON ми все одно хочемо "tags": []
dto.tags = (task.getTags() == null) ? List.of() : List.copyOf(task.getTags());
// Прапорець "архівності" — похідний, але зручний для клієнта.
dto.archived = (task.getStatus() == TaskStatus.ARCHIVED);
return dto;
}
}
Тут усього кілька рядків, але вони роблять контракт стабільнішим. Тепер клієнт завжди побачить або "tags":[], або "tags":["api","spring"].
Так, це виглядає майже смішно: «ми поклали boolean, який дорівнює порівнянню». Але цей boolean — частина договору. І якщо завтра ви вирішите, що «архівність» — це не тільки статус ARCHIVED, а, скажімо, ще й якась бізнес-логіка, клієнтський контракт залишиться попереднім: archived — це true/false, і все.
Приклад HTTP-відповіді
Уявімо, що GET /api/v1/tasks/{taskId} повернув TaskDetailsResponse. Тоді JSON може бути таким:
### Деталі задачі
GET http://localhost:8080/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000
Accept: application/json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Написати документацію",
"status": "TODO",
"archived": false,
"description": null,
"assigneeName": null,
"dueDate": "2026-03-25",
"tags": []
}
І ось це вже схоже на нормальний контракт: tags завжди масив, archived завжди boolean, status завжди рядок із довідника.
7. Типові помилки в JSON-контракті
У цій темі помилки зазвичай не компіляційні. Компілятор ввічливо мовчить, застосунок навіть відповідає 200 OK, і саме тому помилка особливо підступна: ви створюєте контракт, який незручний і непередбачуваний. Нижче — найчастіші «граблі» саме про значення, а не про анотації та налаштування.
Помилка № 1: змінювати рядки enum «бо так гарніше».
Перейменування констант у TaskStatus або TaskPriority майже завжди означає зміну публічного контракту. Навіть якщо ви «просто скоротили» або «зробили читабельніше», клієнти почнуть надсилати старі значення, а сервер — відповідати новими. У підсумку ламаються запити, фільтри, тести й документація. Якщо вже дуже хочеться «гарніше», це має бути окреме рішення на рівні контракту, а не випадковий рефакторинг enum.
Помилка № 2: віддавати колекції як null, а потім дивуватися NullPointerException у клієнтів.
Коли tags інколи [], а інколи null, клієнт змушений писати зайвий захисний код. А частина клієнтів — особливо простих — не зробить цього і просто впаде. У відповідях майже завжди простіше домовитися: «колекції не null». І забезпечити це в мапінгу, а не сподіватися на охайність.
Помилка № 3: використовувати примітивний boolean там, де поле опціональне.
Якщо вхідне поле може не прийти, примітив перетворює «не прийшло» на конкретне значення (зазвичай false). Це ламає зміст: ви вже не відрізните «клієнт явно сказав false» від «клієнт нічого не сказав». Для опціональних boolean-полів (особливо в критеріях пошуку) частіше потрібен Boolean, щоб null лишався «не задано».
Помилка № 4: плутати null і порожній рядок.
"description": "" і "description": null — це різні сигнали. Порожній рядок часто виглядає як «значення є, просто порожнє», а null — як «значення немає». Якщо ви не домовилися, що означає порожній рядок, ви отримаєте дивну поведінку: десь порожній рядок вважатиметься «немає опису», а десь — «опис є, але порожній». Для API краще мати одне чітке правило і тримати його всюди.
Помилка № 5: вважати, що відсутність поля завжди дорівнює null.
У request DTO для посилальних типів відсутність поля часто справді перетворюється на null, але на рівні контракту відсутність поля — окремий сигнал. Якщо ви колись почнете розрізняти «не передали поле» і «передали null», раптом виявиться, що звичайний DTO не завжди вам допоможе. Тому краще заздалегідь обирати домовленості, які не вимагають таких тонких відмінностей, якщо вони вам не потрібні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ