enum, boolean і null у JSON

Spring REST & MVC
Рівень 11 , Лекція 3
Відкрита

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;
}

Якщо archivedBoolean, то відсутність поля/параметра можна інтерпретувати як «не фільтрувати за архівністю», а 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 не завжди вам допоможе. Тому краще заздалегідь обирати домовленості, які не вимагають таких тонких відмінностей, якщо вони вам не потрібні.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ