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;
}
Если 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": "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 не всегда вам поможет. Поэтому лучше заранее выбирать договорённости, которые не требуют таких тонких различий, если они вам не нужны.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ