enum, boolean и null в JSON

Spring REST & MVC
11 уровень , 3 лекция
Открыта

1. Мелочи JSON как правила контракта

Когда вы пишете API, легко попасть в ловушку: “главное, чтобы endpoint работал, а там JSON как-нибудь”. Но клиенту “как-нибудь” не подходит. Клиенту нужно понимать, что означает каждое значение: status = "DONE" — это факт, tags = [] — это тоже факт, а вот description = null — уже тонкая семантика. Эти детали превращаются в договорённости, которые потом тяжело менять.

Представьте, что ваш API — это не просто “ответ сервера”, а инструкция по эксплуатации для клиента. И вот вы в инструкции пишете: “кнопка может быть, а может не быть; если её нет — это нормально; если она есть, но пустая — это тоже нормально”. Клиент начинает писать код, который похож на параноика: проверки на null, проверки на пустоту, проверки на “а вдруг поля нет”. В итоге “мелочи” становятся либо источником багов, либо причиной, почему клиентская команда вас тихо ненавидит (а потом громко).

В этой лекции мы разберём пять “мелочей”, которые на практике оказываются очень большими: enum, boolean, null, пустые коллекции и отсутствующие поля. И главное — научимся фиксировать для них смысл, чтобы API было предсказуемым.

Для краткости ниже несколько DTO будут показаны как сокращённые фрагменты с public fields. Это не новый project-wide style и не канонический вид файлов проекта; так просто легче сосредоточиться на значении JSON-полей, а не на бойлерплейте.

2. enum как словарь API

С enum обычно всё выглядит мило: в Java это аккуратный список допустимых значений, в JSON это строка. Но именно из-за этой “простоты” люди часто расслабляются и начинают менять enum как будто это внутренний код, который никто не видит. А он виден — он торчит наружу ровно как часть контракта. Переименовали IN_PROGRESS в INWORK — и внезапно поломали клиентов, которые честно парсили строку.

В нашем Task Tracker API enum — это публичный словарь. Он описывает состояния и приоритеты так, чтобы клиент мог на них опираться. И тут полезно мыслить так: значения enum — это почти как URL endpoint’а. Они живут долго, и менять их “по приколу” нельзя.

Начнём с базового: как 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": "Write docs",
  "status": "TODO"
}

Здесь есть важная договорённость: клиент должен отправлять и ожидать ровно эти строки. Обычно это означает, что значения enum в API пишут в одном стиле (часто UPPER_CASE), и этот стиль становится частью контракта.

Чтобы почувствовать проблему, посмотрим на маленький негативный сценарий. Допустим, клиент отправил "in_progress" вместо "IN_PROGRESS". Для Jackson это, как правило, другое значение, и он не сможет “угадать”. В итоге запрос не дойдёт до сервисного слоя — сломается на стадии десериализации.

Например, такой JSON (для какого-нибудь будущего update endpoint’а) будет проблемным:

{
  "status": "in_progress"
}

И это нормально. API не обязан угадывать за клиента. Но тогда ваша задача как разработчика API — сделать так, чтобы правильные значения были очевидны: в примерах, документации и стабильном контракте.

Ещё одна мысль, которая очень помогает: enum значения — это не UI-текст. Не надо делать DONE"Выполнено" в API. UI-текст — это работа клиента (или отдельного слоя локализации), а API должен быть машинно-ориентированным.

3. Boolean-флаги в JSON

Boolean кажется самым простым типом в мире: 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": "Write docs",
  "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": "Write docs"
}

то description и tags в Java будут null. А если клиент прислал:

{
  "title": "Write docs",
  "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. Это не про “идеально” — это про “последовательно”.

Ниже — удобная “памятка договора” именно для задач. Она не заменяет документацию, но помогает вам как разработчику держать модель в голове и писать mapping без сюрпризов.

Поле в 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”, значит вы должны обеспечить это в mapping и 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 может быть таким:

### Task details
GET http://localhost:8080/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000
Accept: application/json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Write docs",
  "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”. И обеспечить это в mapping, а не надеждой на аккуратность.

Ошибка №3: использовать примитивный boolean там, где поле опциональное.
Если входное поле может не прийти, примитив превращает “не пришло” в конкретное значение (обычно false). Это ломает смысл: вы уже не отличите “клиент явно сказал false” от “клиент ничего не сказал”. Для опциональных boolean-полей (особенно в критериях поиска) чаще нужен Boolean, чтобы null оставался “не задано”.

Ошибка №4: путать null и пустую строку.
"description": "" и "description": null — это разные сигналы. Пустая строка часто выглядит как “значение есть, просто пустое”, а null как “значения нет”. Если вы не договорились, что означает пустая строка, вы получите странное поведение: где-то пустая строка будет считаться “нет описания”, а где-то — “описание есть, но пустое”. Для API лучше иметь одно чёткое правило и держать его везде.

Ошибка №5: считать, что отсутствие поля всегда равно null.
В request DTO для ссылочных типов отсутствие поля часто действительно превращается в null, но на уровне контракта отсутствие поля — отдельный сигнал. Если вы когда-нибудь начнёте различать “не передали поле” и “передали null”, внезапно окажется, что обычный DTO не всегда вам поможет. Поэтому лучше заранее выбирать договорённости, которые не требуют таких тонких различий, если они вам не нужны.

1
Задача
Spring REST & MVC, 11 уровень, 3 лекция
Недоступна
Предсказуемый JSON для enum, boolean, null и пустого списка
Предсказуемый JSON для enum, boolean, null и пустого списка
1
Задача
Spring REST & MVC, 11 уровень, 3 лекция
Недоступна
Явный null и отсутствующее поле через разные response DTO
Явный null и отсутствующее поле через разные response DTO
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ