JavaRush /Курсы /Spring REST & MVC /Финальная сборка response DTO

Финальная сборка response DTO

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

1. Система response DTO вместо россыпи

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

Системная сборка response DTO решает сразу несколько прикладных проблем. Во‑первых, клиенту проще: он быстро учится распознавать “сводку”, “детали” и “список” по одному и тому же паттерну. Во‑вторых, вам проще развивать контракт: вы заранее фиксируете, что список — это всегда envelope, детали — это отдельная форма, а поля называются одинаково везде, где имеют один смысл. В‑третьих, контроллеры и мапперы перестают быть местом, где “на коленке” решают, как сегодня будет выглядеть JSON.

Дальше мы закрепим для Task Tracker API три опорные точки:

  1. Summary: компактная модель для списка.
  2. Details: более полная модель для деталки (и обычно для create/update-ответов, если вы отдаёте тело).
  3. 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. Если уж очень хочется поменять имя — это отдельное контрактное решение, а не “давайте поправим нейминг”.

1
Задача
Spring REST & MVC, 14 уровень, 4 лекция
Недоступна
Финальный list-response для задач
Финальный list-response для задач
1
Задача
Spring REST & MVC, 14 уровень, 4 лекция
Недоступна
Согласованные summary и details для одной задачи
Согласованные summary и details для одной задачи
1
Опрос
JSON Контракт, 14 уровень, 4 лекция
Недоступен
JSON Контракт
Структура ответов API
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ