1. Корневая форма JSON как часть контракта
Когда мы пишем list-endpoint, например GET /api/v1/tasks, очень легко думать так: «Ну это же список — значит верну List<TaskSummaryResponse>». На уровне Java-кода это выглядит невинно и даже уютно. Но Spring MVC через Jackson превратит ваш ответ в JSON, а у JSON есть важный вопрос: что находится в корне документа — массив или объект?
Корневой элемент JSON — это не косметика и не «деталь сериализации». Клиенты (frontend, мобильные приложения, интеграции) пишут код, который ожидает конкретную форму. Для них «ответ — это массив» и «ответ — это объект с полем items» — два разных мира. И если вы поменяете это позже, клиенту станет больно. В терминах API-контракта это почти как переименовать endpoint или поменять метод с GET на POST.
Чтобы увидеть, насколько это принципиально, представим два ответа, оба «про список задач», но с разной корневой формой:
[
{ "id": "t1", "title": "Write docs" }
]
и:
{
"items": [
{ "id": "t1", "title": "Write docs" }
]
}
Это не «почти одно и то же». Это два разных контракта.
2. Raw array: корень ответа — JSON-массив
Raw array (голый массив) часто появляется естественно: вы возвращаете из контроллера List<...> — и Jackson честно сериализует это как JSON-массив. Такой подход реально удобен, когда вы делаете первый прототип или хотите максимально простой ответ без дополнительных обещаний.
Для этого вопроса нам достаточно очень компактного summary DTO: сейчас важно увидеть форму корня ответа, а не полный состав полей задачи. Допустим, у нас есть укороченная summary-форма только с id и title:
import java.util.List;
// DTO для списка (summary-версия): минимум полей, чтобы не раздувать list-ответ
record TaskSummaryResponse(String id, String title) {}
А теперь контроллер, который возвращает список напрямую:
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class TaskDemoController {
@GetMapping("/api/v1/tasks/raw")
List<TaskSummaryResponse> listRaw() {
// Возвращаем список напрямую: корнем JSON станет массив
return List.of(new TaskSummaryResponse("t1", "Write docs"));
}
}
Что увидит клиент? JSON-массив в корне:
[
{ "id": "t1", "title": "Write docs" }
]
Почему это может быть нормальным решением? Потому что raw array честно говорит: «У меня есть только список элементов, никаких дополнительных данных про этот список я не обещаю». Это похоже на ситуацию, когда вы пришли в магазин, купили хлеб, и вам выдали хлеб, а не хлеб, упакованный в коробку с паспортом хлеба, инструкцией по эксплуатации и сертификатом на моральный износ. Иногда коробка реально лишняя.
Но есть нюанс: raw array «хорош», пока вы действительно уверены, что список — единственное, что вы хотите отдавать.
3. Raw array и метаданные: тупик
Проблема raw array обычно не проявляется в первый день разработки. Она проявляется в тот момент, когда вы говорите (или вам говорят): «А можно ещё…». И вот это «ещё» очень часто относится не к элементам списка, а к самому ответу как к списку.
Например, бизнес говорит: «Покажи общее количество задач, чтобы интерфейс мог написать “Найдено 42 задачи”». Или пользовательский сценарий говорит: «Хочу понимать, что мне отдали актуальные данные, и в каком порядке они отсортированы». Или вы хотите добавить в ответ предупреждение: «Результаты обрезаны» (без объяснения как и почему — просто факт).
Если корень ответа — массив, у вас нет удобного места, куда положить метаинформацию. И здесь начинаются три типичных «плохих пути»:
Первый путь — вы начинаете запихивать метаданные внутрь каждого элемента. То есть вместо чистых задач вы внезапно делаете элементы вида { task: {...}, totalElements: 42 }. Список раздувается, данные дублируются, клиент начинает ругаться (и по делу), а вы потом ещё и объясняете себе, что «ну зато без обёртки». Это примерно как приклеивать чек с общей суммой к каждому товару отдельно.
Второй путь — вы пытаетесь вынести метаданные в HTTP-заголовки. Иногда это допустимо, но часто превращается в скрытый контракт: клиент должен знать, что есть заголовок X-Total-Count, должен помнить его имя, формат, когда он есть, когда нет. JSON-контракт становится неполным без документации заголовков, а документация (в реальной жизни) редко бывает идеальной.
Третий путь — вы делаете то, чего хотели избежать: меняете корневую форму ответа. То есть раньше клиент получал массив, а теперь вдруг получает объект. И вот это уже настоящая проблема: клиентский код, который делал «распарси JSON как массив», падает. Причём падает не «по смыслу», а тупо потому что JSON стал другой формы.
Схематично это выглядит так:
flowchart TD
%% Если нужен рост контракта, лучше заранее иметь объект в корне
A["Сегодня: ответ = массив"] --> B["Нужны метаданные"]
B --> C["Попытки хаков: дублировать метаданные / заголовки"]
B --> D["Или поменять корень на объект"]
D --> E["Клиент ломается: ожидал массив"]
Вот почему даже «простой вопрос про список» на самом деле про устойчивость контракта.
4. List envelope: корень — объект и поле items
List envelope (обёртка списка) — это подход, где корень ответа всегда объект, а сам список лежит в одном предсказуемом поле (обычно items). Смысл простой: вы заранее делаете место для роста контракта, даже если сегодня вам кажется, что «роста не будет». Спойлер: рост будет. Он всегда приходит. Он умеет открывать двери без ключей.
Пока возьмём минимальный envelope только с items: здесь мы фиксируем сам факт объектного корня, а не окончательный list-контракт проекта. Самый простой envelope для задач может выглядеть так:
import java.util.List;
// Envelope-DTO: фиксируем корень ответа как объект, а список кладём в items
record TaskListResponse(List<TaskSummaryResponse> items) {}
И контроллер:
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@GetMapping
TaskListResponse list() {
// Корень ответа теперь объект, поэтому контракт расширяемый (можно добавлять поля рядом с items)
return new TaskListResponse(List.of(new TaskSummaryResponse("t1", "Write docs")));
}
}
Теперь ответ будет таким:
{
"items": [
{ "id": "t1", "title": "Write docs" }
]
}
Главная польза envelope здесь не в том, что «мы добавили ещё один уровень». Польза в том, что мы зафиксировали корневую форму ответа как объект. А это означает, что позже вы можете добавить новые поля рядом с items, не меняя корень.
Например, вы можете (когда понадобится) добавить totalElements — и корень останется объектом. Клиент, который читает items, продолжит работать. Да, он увидит новое поле, но это обычно не проблема: большинство клиентов игнорируют неизвестные поля в ответе. Главное — вы не ломаете базовую форму.
И ещё одна важная деталь: поле items — это не прихоть. Оно очень хорошо читается. Это не абстрактное data, не туманное result, не «магическое» payload. items буквально говорит: «вот элементы списка». Даже если человек только что проснулся, а JavaScript-парсер уже нет — смысл будет понятен.
При этом важно не переусложнять. Envelope почти всегда должен быть плоским. В 90% случаев достаточно одного объекта-обёртки. Конструкция вида { "data": { "items": [...] } } обычно делает только хуже: появляются лишние уровни, и клиенту приходится писать больше кода ради нулевой пользы.
5. Рефакторинг: оборачиваем список задач
Сейчас самое приятное: это не теоретический спор «массива против объекта», а вполне конкретный шаг в проекте. Допустим, в вашем TaskController уже есть list-endpoint, который возвращает List<TaskSummaryResponse>. Это типичный «первый честный вариант», и он неплох… пока вы не приняли решение, что контракт должен быть расширяемым.
Давайте сделаем минимальный refactor в стиле курса: контроллер остаётся тонким, сервис остаётся без MVC-типов, DTO живут в api.dto.response, mapping остаётся явным. Сам TaskListResponse здесь нарочно минимальный: нам нужно перевести корень ответа в объект, не смешивая это решение с метаданными листинга.
Сначала добавим response DTO-обёртку:
import java.util.List;
// Отдельный DTO под list-ответ: в будущем сюда можно добавить метаданные (totalElements, sort и т.д.)
public record TaskListResponse(List<TaskSummaryResponse> items) {
}
Теперь перепишем метод контроллера (схематично, без деталей критериев поиска):
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping
TaskListResponse list() {
// Достаём доменные задачи из сервиса и маппим в summary DTO для списка
var items = taskService.list().stream()
.map(taskMapper::toSummaryResponse) // Явный mapping: контроллер не должен отдавать доменную модель
.toList();
// Возвращаем envelope, чтобы корень ответа был объектом (контракт можно расширять без ломания клиентов)
return new TaskListResponse(items);
}
Здесь важно, что мы не меняем вашу доменную модель, не «перепридумываем сервис», не уходим в новую архитектуру. Мы просто говорим: «Список задач в API — это не массив как корень. Это объект-ответ, в котором есть items». Всё. Простой шаг, но он фиксирует контракт.
И да, это тот случай, когда «лишний DTO» на самом деле экономит вам время. Он экономит будущий рефакторинг вида «ой, нам нужно добавить метаданные, но клиенты уже ожидают массив, что же делать…». Поверьте, такой рефакторинг всегда случается в пятницу вечером. Всегда.
6. Выбор: raw array или envelope
Выбор между raw array и envelope не должен превращаться в «священную войну REST-племён». Это инженерная развилка, и у неё есть понятные критерии. Важно не то, «как принято где-то», а то, какую жизнь вы хотите для контракта вашего API.
Ниже — небольшая таблица, которая обычно помогает принять решение без лишнего драматизма:
| Вопрос | Raw array в корне | Envelope ({ "items": [...] }) |
|---|---|---|
| Ответ — это только список и больше ничего? | Часто подходит | Тоже подходит, но чуть многословнее |
| Нужны (или почти наверняка появятся) метаданные ответа? | Быстро упрётесь в стену | Отлично поддерживает рост |
| Важно иметь единый стиль list-ответов в API | Обычно сложно удержать | Обычно легче удержать |
| Хотим заранее защититься от «ломающего» изменения корня | Нет, риск остаётся | Да, корень уже объект |
| Клиентам удобнее работать с единым shape | Реже (зависит) | Чаще (особенно для UI) |
Если говорить про Task Tracker API, где список задач — один из центральных endpoints, envelope выглядит более взрослым выбором. А вот для маленького lookup-endpoint, например списка тегов, raw array иногда бывает приемлем. Но даже в этом случае полезно быть последовательным: либо вы договорились, что lookup-ответы — массивы, либо что всё, что «про список», всегда имеет { items: ... }.
Самая дорогая ошибка здесь — не выбор «массив или объект», а выбор «как попало». Клиентам обычно всё равно, какой стиль вы выбрали. Им важно, чтобы он был предсказуемым.
Небольшая практика
Чтобы тема не осталась «философией формы JSON», полезно руками увидеть разницу в ответах и почувствовать, что именно меняется для клиента. В идеале вы делаете это на своём же Task Tracker API, чтобы контекст не растворился в абстракциях.
1) Добавьте временный демонстрационный endpoint, например GET /api/v1/tasks/raw, который возвращает List<TaskSummaryResponse>. Сделайте запрос из .http-файла и посмотрите на корень ответа: это будет массив.
2) Основной list-endpoint GET /api/v1/tasks оберните в TaskListResponse с полем items. Снова сделайте запрос из .http-файла и сравните ответы глазами клиента: теперь корень — объект, и список живёт в items.
Если хотите совсем наглядно, можете сделать два запроса рядом:
### raw array
GET http://localhost:8080/api/v1/tasks/raw
Accept: application/json
### envelope
GET http://localhost:8080/api/v1/tasks
Accept: application/json
Ваша цель — не «написать больше кода», а закрепить, что корень ответа — это часть контракта, а не мелочь.
7. Типичные ошибки при выборе формы list-ответа
В этой теме ошибки особенно коварны тем, что они не выглядят ошибками, пока проект маленький. А когда проект вырастает, исправлять становится дорого, потому что это уже не рефакторинг кода, а изменение внешнего контракта, от которого зависят клиенты.
Ошибка №1: “Пока вернём массив, а обёртку добавим потом”.
Это звучит логично, пока «потом» не наступает. Если клиенты уже используют API и ожидают корневой массив, переход на { "items": [...] } становится breaking change. То есть вы ломаете клиентский парсинг не потому, что бизнес-логика изменилась, а потому что вы передумали форму ответа.
Ошибка №2: разные имена списка в разных ответах (items, data, results, list).
Кажется, что это мелочь, но для клиента это лишние условия и ветвления. А ещё это просто раздражает (иногда незаметно, но стабильно). Лучше выбрать одно слово и везде его держать. В учебном проекте items — отличный кандидат: коротко и по делу.
Ошибка №3: обёртка ради обёртки с лишними уровнями вложенности.
Формат { "data": { "items": [...] } } обычно не несёт ценности. Он делает JSON глубже, клиентский код длиннее, а смысл не добавляет. Если вы хотите envelope — пусть это будет один корневой объект с полем items и (когда понадобится) парой полей рядом.
Ошибка №4: метаданные зашиваются в каждый элемент списка.
Попытка добавить totalElements или sort внутрь каждого элемента обычно приводит к дублированию и росту размера ответа. Метаданные относятся к ответу, а не к каждому элементу. Поэтому правильное место для них — рядом с items в обёртке.
Ошибка №5: list endpoint отдаёт “детальный” DTO, потому что “ну чего уж там”.
Список задач часто используется чаще, чем detail-endpoint, и его хочется сделать лёгким. Если вы тянете в список поля, которые нужны только на экране деталей, вы увеличиваете ответ, усложняете контракт и повышаете шанс случайных breaking changes. Summary-DTO для списка — почти всегда более здравая форма.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ