1. Система response DTO вместо россыпи
Если вы когда-нибудь видели проект, где ответы API выглядят как набор случайных “то так, то сяк”, вы знаете ощущение: будто попал в квартиру, где у каждого чайника свой стандарт розеток. Оно вроде работает, но жить в этом страшно, и гостей лучше не звать. В API ровно та же история: пока у вас два эндпоинта — кажется, что всё нормально, но как только появляются новые поля, новые представления и новые сценарии, отсутствие единого подхода превращает проект в пазл без картинки на коробке.
Системная сборка response DTO решает сразу несколько прикладных проблем. Во‑первых, клиенту проще: он быстро учится распознавать “сводку”, “детали” и “список” по одному и тому же паттерну. Во‑вторых, вам проще развивать контракт: вы заранее фиксируете, что список — это всегда envelope, детали — это отдельная форма, а поля называются одинаково везде, где имеют один смысл. В‑третьих, контроллеры и мапперы перестают быть местом, где “на коленке” решают, как сегодня будет выглядеть JSON.
Дальше мы закрепим для Task Tracker API три опорные точки:
- Summary: компактная модель для списка.
- Details: более полная модель для деталки (и обычно для create/update-ответов, если вы отдаёте тело).
- List response: не “просто список”, а PagedResponse<TaskSummaryResponse> с предсказуемым корнем и метаданными.
Чтобы это стало по-настоящему устойчивым, нам ещё нужно согласовать семантику полей: где null — это “значения нет”, где пустой список — это “коллекция есть, но элементов нет”, а где поле вообще не существует, потому что это другое представление ресурса.
Ниже уже не локальные куски под одну идею. Отсюда и считаем рабочими для проекта именно эти формы response DTO и этот list-contract.
2. Канонические ответы для Task
Когда мы говорим “у ресурса разные response DTO”, это не бюрократия “ради красивых папочек”. Это признание факта: список и деталка отвечают на разные вопросы. Список отвечает на вопрос “что у меня вообще есть?” и должен быть лёгким и быстрым для чтения. Деталка отвечает на вопрос “что именно внутри этого объекта?” и имеет право быть более подробной. Если пытаться одним DTO закрыть оба сценария, вы почти гарантированно получите либо тяжёлый список, либо бедную деталку — а иногда (для полного счастья) и то, и другое сразу.
Отсюда договоримся: именно такие формы ответа и считаем рабочими для Task.
Давайте зафиксируем простой принцип: summary DTO — это “карточка”, details DTO — это “страница товара”. Карточка нужна, чтобы пробежать глазами и выбрать, куда кликать. Страница товара нужна, чтобы прочитать всё и принять решение. Если вы когда-нибудь видели интернет-магазин, где в списке сразу показывают полный текст отзывов на 40 экранов — вы уже интуитивно понимаете, почему summary должен быть компактным.
Одна Task: три уровня модели
Ниже — не “единственно правильный” набор полей, а понятная схема, с которой удобно жить в учебном проекте:
| Уровень | Что это | Для кого | Пример |
|---|---|---|---|
| Internal model (domain.model.Task) | Внутреннее состояние | для сервиса/репозитория | может содержать всё, что нужно приложению |
| TaskSummaryResponse | Сводка | для списка | минимум полей для чтения и навигации |
| TaskDetailsResponse | Детали | для detail/create/update | более полное публичное представление |
Важно: внутреннюю модель мы не сериализуем наружу вообще — не потому что “так написано в книжке”, а потому что внутренние поля любят меняться, а клиенты не любят, когда ломают их парсинг.
Если такой DTO сериализовать в JSON, он будет примерно таким:
Пример: TaskSummaryResponse
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
// DTO для списков: только то, что нужно для быстрого просмотра и навигации.
// Важно: это НЕ доменная модель и НЕ пытается быть “универсальным DTO на всё”.
public record TaskSummaryResponse(
String id, // Идентификатор задачи (наружу отдаём строкой как часть контракта)
String title, // Заголовок (чтобы в списке было что читать глазами)
TaskStatus status, // Статус (сигнал состояния)
TaskPriority priority,// Приоритет (сигнал состояния)
boolean archived // Явный флаг: клиенту не нужно “догадываться” по status
) {}
Здесь полезно заметить одну тонкость: archived: false выглядит “банально”, но это как раз тот самый server-side default, который лучше отдать явно. Клиенту не нужно строить догадки “а что значит отсутствие поля?”, он просто видит состояние.
{
"id": "t1",
"title": "Write docs",
"status": "TODO",
"priority": "HIGH",
"archived": false
}
Пример: TaskDetailsResponse
В деталях нам обычно нужны поля вроде description, tags, и, как правило, временные метки (createdAt, updatedAt). В учебном примере я покажу компактную версию, чтобы не превращать DTO в простыню, но принцип остаётся тем же: details DTO богаче, чем summary DTO, и это нормально.
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import java.time.Instant;
import java.util.List;
// DTO для деталки: модель богаче, чем summary, и это нормально.
// Здесь мы фиксируем контрактные решения: какие поля nullable, какие — нет.
public record TaskDetailsResponse(
String id, // Идентификатор задачи
String title, // Заголовок
String description, // Описание: может быть null, если “описания нет”
TaskStatus status, // Статус
TaskPriority priority,// Приоритет
List<String> tags, // Теги: по контракту лучше всегда отдавать список (а не null)
Instant createdAt, // Время создания (пример метаданных домена, которые можно раскрыть наружу)
boolean archived // Явный флаг “в архиве”
) {}
И тут мы должны сделать маленькое, но важное контрактное решение: tags в ответе почти всегда лучше отдавать как список, а не как null. То есть если тегов нет, отдаём [], а не null. Тогда клиенту не нужно писать “танцы с бубном” вида “если null, то считать пустым”.
{
"id": "t1",
"title": "Write docs",
"description": "Prepare API docs",
"status": "TODO",
"priority": "HIGH",
"tags": ["docs", "rest"],
"createdAt": "2026-03-21T10:15:30Z",
"archived": false
}
А вот description часто имеет смысл как nullable: null означает “значения сейчас нет”. Это нормально, если вы не путаете null и “поля не существует”. Поля “не существует” у нас бывает только тогда, когда мы сознательно используем другое представление (summary вместо details).
3. Единый list response: PagedResponse<T>
С корнем list-response вопрос уже закрыт: список приходит не голым массивом, а объектом с items и метаданными. Поэтому здесь не доказываем envelope заново, а просто фиксируем для проекта один формат списков — PagedResponse<T>.
Даже если прямо сейчас ваш list endpoint ещё не делает “настоящую” пагинацию, поля page, size, totalElements, totalPages и sort уже закрепляют форму ответа. Это важно именно как дисциплина контракта: клиент не должен угадывать, почему вчера список был массивом, а сегодня вдруг стал объектом.
Ключевые договорённости здесь на человеческом уровне такие. items всегда существует и всегда список; если элементов нет — это просто пустой список. page и size описывают текущий “срез”. totalElements и totalPages описывают общий размер результата. sort фиксирует порядок выдачи, чтобы клиент не строил фантазии “оно отсортировано само по себе”.
Пример: PagedResponse<T>
import java.util.List;
// Унифицированный envelope для списков.
// Главная цель: стабильный корень ответа + место для метаданных (page/size/total...).
public record PagedResponse<T>(
List<T> items, // Список элементов; по контракту: всегда существует, null не допускаем
int page, // Номер страницы (в проекте фиксируем 0-based)
int size, // Размер страницы
long totalElements, // Сколько всего элементов по запросу (без учёта page/size)
int totalPages, // Сколько всего страниц
String sort // Как отсортировано (чтобы клиент не “фантазировал” про порядок)
) {}
Пример JSON для списка задач:
{
"items": [
{
"id": "t1",
"title": "Write docs",
"status": "TODO",
"priority": "HIGH",
"archived": false
}
],
"page": 0,
"size": 20,
"totalElements": 1,
"totalPages": 1,
"sort": "updatedAt,desc"
}
Это и есть тот самый “стабильный корень”, про который мы говорили: клиент знает, что где бы он ни получал список в стиле PagedResponse, он всегда начнёт с items, а метаданные живут рядом, не смешиваясь с бизнес-элементами.
4. Маппинг: контроллер без копи‑пасты и утечек
Когда response DTO становятся системой, автоматически возникает следующий вопрос: “А где мы будем собирать эти DTO?” Самый плохой ответ — “прямо в контроллере, по месту”. Это быстро превращается в классический fat controller, где половина кода — перекладывание полей из одного объекта в другой. Контроллер должен заниматься web-слоем: принять запрос, вызвать сервис, вернуть ответ. А вот перевод доменной модели в response DTO лучше держать в отдельном, читаемом месте.
Мы в проекте уже договорились, что маппинг будет ручным и прозрачным. Сейчас это особенно важно: в маппере вы фиксируете контрактные решения вроде “tags никогда не null”, “archived вычисляется так-то”, “id наружу отдаём строкой”. И вы делаете это один раз — а не копируете одну и ту же логику в пять методов контроллера.
Пример: TaskResponseMapper
Ниже — маленький пример. Он не претендует на полный production-grade маппер, но показывает “скелет” и важные контрактные места.
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.model.TaskStatus;
public class TaskResponseMapper {
public TaskSummaryResponse toSummary(Task task) {
// Контрактное решение: archived считается на сервере и всегда присутствует в ответе.
boolean archived = task.getStatus() == TaskStatus.ARCHIVED;
// Важно: наружу возвращаем DTO, а не доменную модель (никаких “временно вернули Task”).
return new TaskSummaryResponse(
task.getId(),
task.getTitle(),
task.getStatus(),
task.getPriority(),
archived
);
}
}
Обратите внимание на две вещи. Во‑первых, archived вычисляется явно и одинаково везде. Во‑вторых, наружу мы возвращаем TaskSummaryResponse, а не Task. Это кажется очевидным, пока не увидишь проект, где “временно вернули доменную модель, а потом забыли убрать”.
Для details маппера логика похожа, только мы аккуратно решаем, что делать со списками, чтобы не получить null там, где клиент ожидает коллекцию.
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 TaskResponseMapper {
public TaskDetailsResponse toDetails(Task task) {
// Единая логика вычисления archived, чтобы не было “в одном месте так, в другом иначе”.
boolean archived = task.getStatus() == TaskStatus.ARCHIVED;
// Контрактное решение: tags наружу всегда список, даже если в домене null.
// Дополнительно: List.copyOf(...) защищает от “утечки” изменяемой коллекции наружу.
List<String> tags = task.getTags() == null ? List.of() : List.copyOf(task.getTags());
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus(),
task.getPriority(),
tags,
task.getCreatedAt(),
archived
);
}
}
Да, строка про tags выглядит чуть “занудно”, но это хорошая занудность. Она фиксирует семантику контракта: tags всегда список. А ещё List.copyOf(...) делает список неизменяемым, чтобы вы случайно не передали наружу коллекцию, которую потом кто-то мутирует изнутри. Это не “супер‑безопасность”, это просто дисциплина.
5. Контроллер: финальные list и details с DTO
Контроллер — это то место, где контракт становится публичным. Поэтому он должен быть максимально скучным и предсказуемым. Это звучит странно, но это комплимент: скучный контроллер — значит, не прячет в себе бизнес-решения и не смешивает уровни ответственности. В нашем случае “финальная сборка” означает, что контроллеры всегда возвращают response DTO, а list‑эндпоинты всегда возвращают envelope, а не голый массив.
Ниже — минимальный пример, как может выглядеть GET /api/v1/tasks, если мы уже приняли дисциплину PagedResponse<TaskSummaryResponse>. Я намеренно не ухожу в реализацию поиска/фильтрации/пагинации внутри сервиса — сегодня мы фиксируем форму ответа, а не алгоритм его наполнения.
Пример: list endpoint возвращает PagedResponse<TaskSummaryResponse>
import com.example.tasktracker.api.dto.response.PagedResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@GetMapping
PagedResponse<TaskSummaryResponse> list() {
// В реальном коде элементы приходят из сервиса и маппера.
// Здесь — мини-сниппет, чтобы показать ФОРМУ ответа: envelope + items + метаданные.
List<TaskSummaryResponse> items = List.of(
new TaskSummaryResponse("t1", "Write docs", TaskStatus.TODO, TaskPriority.HIGH, false)
);
// Важно: даже при одном элементе возвращаем объект, а не “голый массив”.
return new PagedResponse<>(items, 0, 20, items.size(), 1, "updatedAt,desc");
}
}
Здесь элементы уже показаны с реальными TaskStatus и TaskPriority, чтобы shape не разваливался даже в схематичном примере. В реальном коде items всё равно придут через сервис + маппер. Главное, что нужно увидеть: корень ответа — объект, и в нём есть items. Даже если задач пока 1, и кажется, что массив был бы короче, “короче” — это удовольствие на пять минут, а контракт — это привычка на месяцы.
Пример: details endpoint возвращает TaskDetailsResponse
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@GetMapping("/{taskId}")
TaskDetailsResponse details(@PathVariable String taskId) {
// Здесь мы показываем идею: деталка — отдельный DTO.
// В реальном коде будет сервис + маппер, но контракт уже должен выглядеть так же.
return new TaskDetailsResponse(
taskId,
"Write docs",
null, // description может быть null по контракту: “описания нет”
TaskStatus.TODO,
TaskPriority.HIGH,
List.of(), // tags лучше отдавать [] (а не null)
Instant.parse("2026-03-21T10:15:30Z"),
false
);
}
}
Снова: пример “скелетный”, но показывает идею. Деталка — это отдельный DTO, а не “те же элементы, что в списке, только случайно больше полей”.
Ниже — небольшая схема, чтобы “щёлкнуло” в голове:
flowchart TD
Client[HTTP client] -->|GET /api/v1/tasks| C1[TaskController.list]
C1 --> S1[TaskService]
S1 -->|List<Task>| M1[TaskResponseMapper.toSummary]
M1 --> R1["PagedResponse<TaskSummaryResponse>"]
R1 --> Client
Client -->|"GET /api/v1/tasks/{id}"| C2[TaskController.details]
C2 --> S2[TaskService]
S2 -->|Task| M2[TaskResponseMapper.toDetails]
M2 --> R2[TaskDetailsResponse]
R2 --> Client
Эта картинка полезна тем, что она подчёркивает границы: контроллер не возвращает domain model, а mapper — не занимается HTTP. Каждый делает своё, и контракт получается предсказуемым.
Чеклист стабильной формы ответа
Когда вы “вроде бы написали DTO”, это ещё не значит, что вы зафиксировали контракт. Контракт — это договорённости: как называется поле, когда оно бывает null, когда оно отсутствует, что считается пустым значением, и одинаково ли это по всему API. На практике помогает маленький чеклист, который вы прогоняете по каждому response DTO (особенно по спискам, потому что списки ломаются первыми).
Чтобы не превращать это в огромную простыню правил, я люблю держать такой компактный “контрактный тест на здравый смысл” в виде таблицы:
| Вопрос | Хороший ответ для проекта |
|---|---|
| Корень list-ответа — массив или объект? | Объект (PagedResponse), чтобы метаданные не ломали корень |
| Где лежит сам список? | В поле items (стабильная точка входа) |
| Может ли items быть null? | Нет, только [] |
| Может ли tags быть null? | Лучше нет, только [] |
| Может ли description быть null? | Да, если “описания нет” — это нормальное состояние |
| Есть ли у одинаковых полей одинаковые имена? | Да: если это title, то везде title, а не name/caption/taskTitle |
| Есть ли у важных состояний явные значения? | Да: archived всегда есть (true/false), а не “иногда поле пропадает” |
| Возвращаем ли мы internal model наружу? | Нет, только response DTO |
Этот чеклист не заменяет документацию и не делает API “идеальным”, но он помогает быстро ловить хаос на ранней стадии. Проблема хаоса в том, что он копится незаметно: вы добавили одно “временное” поле, потом ещё одно “временное”, а потом внезапно обнаружили, что у вас три разных формата списков.
6. Типичные ошибки при финальной сборке response DTO
Ошибка №1: попытка сделать “универсальный DTO на всё”.
Это обычно начинается невинно: “ну зачем мне TaskSummaryResponse и TaskDetailsResponse, сделаю один TaskResponse”. Через неделю вы хотите, чтобы список был компактным, и начинаете либо прятать поля, либо плодить @JsonIgnore, либо “обнулять” часть полей в списке. В итоге клиент получает DTO, где половина полей в list‑ответе всегда null, и это выглядит как договор “мы отдаём всё, но не факт”.
Ошибка №2: разные корневые формы для похожих списков.
Сегодня GET /tasks возвращает envelope, а завтра GET /tags возвращает массив, послезавтра GET /comments возвращает объект с полем data, а через неделю кто-то добавляет ещё один эндпоинт и возвращает просто List<...> “потому что так быстрее”. Клиенту приходится помнить “какой список в какой коробке”, а это прямой путь к багам и раздражению. Лучше выбрать один стиль и держаться его, а исключения делать только осознанно и редко.
Ошибка №3: null вместо пустых коллекций.
tags: null и tags: [] выглядят похожими только на глаз. Для клиента это разные ветки кода. Если tags является частью контракта и логически “список существует всегда”, то лучше отдавать []. null стоит оставить для случаев, где “значения нет” — это реальное состояние (например, assigneeName).
Ошибка №4: утечка внутренних полей или “временных костылей”.
Иногда очень хочется отдать наружу внутренний storageKey, “потому что потом пригодится”, или вернуть internalVersion, “чтобы фронту было проще”. Это почти всегда превращается в публичное обещание, которое потом сложно забрать обратно. Если поле не является частью публичной модели ресурса, не тащите его в response DTO. И да, “мы потом уберём” — классический конкурент “мы потом перепишем” в олимпиаде оправданий.
Ошибка №5: переименование полей ради красоты.
Переименовать assigneeName в assignee кажется мелочью, пока вы не вспомните: клиент парсит JSON по ключам. Внутри Java‑кода это может выглядеть как безобидный рефакторинг, но для клиента это breaking change. Если уж очень хочется поменять имя — это отдельное контрактное решение, а не “давайте поправим нейминг”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ