1. Отдельные DTO для API
Как только внутренняя модель зафиксирована, сразу видно следующую проблему: наружу её нельзя просто отдать как есть.
Когда вы впервые делаете локальное API, очень хочется «сэкономить» и использовать один и тот же класс везде: пусть ReadingListItem будет и тем, что хранится в памяти, и тем, что приходит/уходит в JSON. Это выглядит как быстрый путь, но на практике быстро превращается в ловушку. Внутренний объект живёт по правилам приложения, а внешний контракт живёт по правилам клиентов: у него другая ответственность, другие требования к стабильности, и он должен быть удобным для потребителя.
Если смотреть на это как на обычный договор: доменная модель — это «как мы думаем внутри», а DTO — это «как мы разговариваем снаружи». И если вы однажды смешали эти два языка, вы потом будете либо ломать клиентам контракт при каждом внутреннем рефакторинге, либо бояться рефакторинга и держать внутри монстра «потому что наружу так привыкли».
Чтобы не быть голословными, представьте простой вопрос: должен ли клиент присылать id при создании элемента? Внутри домена id нужен почти всегда, но в create-запросе чаще всего id генерирует сервер. Значит, request DTO и domain object уже отличаются, даже если остальные поля похожи.
Ниже мы зафиксируем два типа входящих DTO (create и full update), один узкий DTO для изменения статуса, и два типа ответов: один элемент и список элементов.
2. Create vs Update DTO
Снаружи может показаться, что запрос на создание и запрос на полное обновление — одно и то же: «прислали title/author/status/externalId/comment». Но с точки зрения смысла это две разные операции. Create-запрос говорит: «создай новый элемент, вот данные». Update-запрос говорит: «вот полная замена данных существующего элемента». Даже если JSON выглядит одинаково, в коде лучше держать разные DTO, потому что разные операции обычно дают разные правила валидации, разные ошибки и разную семантику обработки.
Ещё одна важная деталь: в request DTO обычно нет id. Почему? Потому что id — это идентичность ресурса на стороне сервера. В нашем будущем API id будет жить в path (/api/v1/reading-list/{id}) или генерироваться сервером при создании. Если положить id в request body «для симметрии», вы создадите сразу две проблемы: можно прислать конфликтующие значения (path говорит одно, body — другое), и вы начнёте хранить логику «а кому верить?» вместо реальной прикладной работы.
В ReadLater Starter мы остаёмся в простом мире, поэтому оба request DTO будут одинаковыми по полям — и это нормально. Разделение нужно не ради «ритуала архитектуры», а ради понятной семантики и будущей дисциплины.
Request DTO на record
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
public record CreateReadingItemRequest(
// Название книги/статьи, которое вводит пользователь
String title,
// Автор в свободной форме (для учебного проекта достаточно строки)
String author,
// Статус чтения в момент создания (например, PLANNED)
ReadingStatus status,
// Внешний идентификатор (может отсутствовать)
String externalId,
// Комментарий пользователя (может отсутствовать)
String comment
) {
// Важно: id здесь нет — при создании он обычно генерируется на сервере.
}
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
public record UpdateReadingItemRequest(
// Полная замена title: клиент присылает итоговое значение
String title,
// Полная замена author
String author,
// Полная замена status (даже если меняем только его — лучше иметь отдельный PATCH DTO)
ReadingStatus status,
// Полная замена externalId (может быть null)
String externalId,
// Полная замена comment (может быть null)
String comment
) {
// Важно: id здесь тоже нет — он приходит из path: /api/v1/reading-list/{id}
}
Обратите внимание, что мы используем ReadingStatus из домена. Для этого курса это хороший компромисс: статус должен быть одинаковым внутри и снаружи, иначе вы сами себе устроите квест «маппинг статусов» без реальной ценности. В большом продукте иногда делают отдельный enum для API-слоя, но здесь это преждевременно.
JSON для create и update
Create-request (будущий POST /api/v1/reading-list) может выглядеть так:
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
Update-request (будущий PUT /api/v1/reading-list/{id}) будет такой же по форме, и это нормально. Мы разделяем их по смыслу, а не по «форме ради формы».
3. UpdateStatusRequest: узкий DTO для узкого сценария
Частичное обновление — это один из тех моментов, где DTO особенно помогают: они делают контракт ясным и честным. Когда вы обновляете только статус, вам не нужно заставлять клиента пересылать всё остальное. Если клиенту нужно прислать title, author, externalId, comment просто ради того, чтобы поменять status, это быстро превращается в неудобный API и источник ошибок «ой, мы перезатёрли comment старым значением».
Поэтому мы вводим отдельный request DTO, который описывает ровно один смысл: «меняю статус». Он маленький, его приятно читать, и по нему сразу понятно, что именно поменяется в доменной сущности. И да — это тот случай, когда «ещё один класс» на самом деле уменьшает сложность, а не увеличивает.
DTO для смены статуса
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
public record UpdateStatusRequest(
// Меняем только статус и ничего больше
ReadingStatus status
) {
// Этот DTO хорошо подходит для PATCH /reading-list/{id}/status
}
JSON для будущего PATCH /api/v1/reading-list/{id}/status будет минимальным:
{
"status": "FINISHED"
}
Такой контракт почти невозможно неправильно понять. Он прямолинейный, как хороший git commit: маленький и про одно изменение.
4. ReadingItemResponse: карточка
Response DTO — это форма данных, которую получит клиент (Postman, фронтенд, другой сервис, будущий вы). И тут очень важно не «отдать как есть», а отдать то, что имеет смысл для клиента. В нашем учебном проекте ответ будет почти повторять доменные поля, но мы всё равно фиксируем отдельный тип: так мы сохраняем свободу менять домен и хранение, не ломая контракт.
Ещё важный момент: в response почти всегда есть id, даже если в request его не было. Это и есть «сервер сообщил идентичность созданного/найденного ресурса». В списке ответов id тоже нужен, иначе как клиенту обращаться к конкретному элементу дальше?
Наконец, про externalId и comment. Они могут быть null, и это нормально. Клиент должен быть готов к тому, что поля необязательные. Мы пока не вваливаемся в сложную политику «не отдаём null вообще» — это отдельная дисциплина. На уровне bridge-курса нам важнее стабильная форма и простая читаемость.
Response DTO элемента
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
public record ReadingItemResponse(
// Идентификатор ресурса, задаётся сервером
long id,
// Текущее значение title
String title,
// Текущее значение author
String author,
// Текущее значение status
ReadingStatus status,
// Может быть null, если внешний id не задан
String externalId,
// Может быть null, если комментария нет
String comment
) {
// Response DTO обычно стабилен: домен можно рефакторить, контракт — сохранять.
}
И пример JSON-ответа (будущий GET /api/v1/reading-list/1):
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
Даже если в памяти мы храним объект иначе (допустим, захотим хранить автора отдельным объектом или добавим технические поля), этот JSON может остаться прежним — и клиенту будет всё равно. В этом и смысл отдельного response DTO.
5. ReadingListResponse: items и count
Когда вы делаете GET /reading-list, есть соблазн вернуть просто массив:
[
{ ... },
{ ... }
]
Это быстро, но у такого ответа есть проблема: он почти не расширяем. Как только вы захотите добавить мета-информацию (например, count, или позже filters, или хотя бы serverTime), вы сломаете контракт — вместо массива станет объект. Клиентам придётся переписывать код не потому, что поменялась логика, а потому, что вы «не подумали на два шага вперёд».
Ответ-обёртка решает это сразу: мы всегда возвращаем объект, внутри которого есть список и метаданные. И да, count можно посчитать по длине массива, но наличие count в контракте даёт два преимущества: во-первых, он делает ответ самодостаточным (клиенту не нужно «вычислять»), во-вторых, он естественно расширяется (позже можно добавить, например, totalCount, если появятся ограничения выдачи). Мы не будем делать пагинацию и сложные мета-поля в этом курсе, но сама форма ответа уже дисциплинирует.
Response DTO списка
package com.example.readlater.readinglist.dto;
import java.util.List;
public record ReadingListResponse(
// Список элементов в текущей выборке
List<ReadingItemResponse> items,
// Количество элементов (обычно совпадает с items.size(), но полезно как мета-поле)
int count
) {
// Обёртка позволяет позже добавлять метаданные без поломки контракта.
}
Пример JSON-ответа (будущий GET /api/v1/reading-list):
{
"items": [
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
],
"count": 1
}
Если список пустой, это всё равно будет корректный ответ:
{
"items": [],
"count": 0
}
И это, кстати, приятный UX: пустой список — это «данных нет», а не «ресурс не найден». Для коллекции 404 обычно не нужен.
6. Карта DTO: запросы и ответы
Когда DTO-набор становится больше пары классов, мозг начинает просить карту. Не диаграмму на 30 страниц, а простую «шпаргалку» в рамках проекта. Это особенно важно новичкам: пока нет опыта, легко перепутать «что куда» и случайно начать использовать response-модель как request-модель просто потому, что поля похожи.
Ниже — компактная таблица для нашей будущей server-фазы. Здесь важно увидеть именно связку «операция → DTO», а не детали реализации.
| Операция локального API (позже) | HTTP смысл | Request DTO | Response DTO |
|---|---|---|---|
| Создать элемент | create | CreateReadingItemRequest | ReadingItemResponse |
| Полностью обновить элемент | full replace | UpdateReadingItemRequest | ReadingItemResponse |
| Обновить только статус | partial update | UpdateStatusRequest | ReadingItemResponse |
| Получить один элемент | read | (нет body) | ReadingItemResponse |
| Получить список | read collection | (нет body) | ReadingListResponse |
Эта таблица полезна ещё и тем, что показывает: request DTO не обязаны «симметрично» совпадать с response DTO. Симметрия в API — не цель, цель — удобство и предсказуемость контракта.
7. Пакеты DTO и record
Сейчас у вас может появиться практический вопрос: «Окей, я понял смысл DTO. А куда их класть и как не устроить бардак?» Для нашего проекта правило максимально простое: всё, что относится к reading list как к фиче, хранится внутри com.example.readlater.readinglist.*. Домен — в readinglist.domain, DTO — в readinglist.dto. Это соответствует нашей структуре package-by-feature и не превращает common в свалку «потому что DTO вроде общие».
Выбор record для DTO — это не «модно», а прагматично. DTO — это форма данных. Нам не нужно там сложное поведение, наследование и 50 строк equals/hashCode/toString. record даёт компактность, читаемость и честность: это просто контейнер значений. При этом для доменной сущности (ReadingListItem) record часто неудобен, потому что доменный объект меняется (статус обновляется, комментарий редактируется). Поэтому комбинация «domain = class, DTO = record» в нашем курсе будет встречаться часто — и это нормально.
Если вы сейчас добавляете эти классы в проект, то вполне разумный шаг — создать пакет:
src/main/java/com/example/readlater/readinglist
└─ dto
и положить туда пять файлов:
CreateReadingItemRequest.java
UpdateReadingItemRequest.java
UpdateStatusRequest.java
ReadingItemResponse.java
ReadingListResponse.java
После этого проект уже станет «контрактно готовым»: у вас будут явные модели, с которыми будут работать JSON-сериализация/десериализация, mapping и обработчики запросов. Поэтому аккуратно сделанные формы данных экономят больше времени, чем самый быстрый Ctrl+C/Ctrl+V.
8. Типичные ошибки при проектировании request/response DTO
Ошибка №1: один класс «на всё» (и для create, и для update, и для response).
Это выглядит как экономия, но фактически вы теряете смысл операции. В результате вы начинаете писать комментарии «это поле тут не используется», «а это только в response», «а это только в PUT». DTO перестаёт быть контрактом и превращается в свалку компромиссов. Отдельные типы с одинаковыми полями — это нормально, если смысл разный.
Ошибка №2: id в create-request «для симметрии».
Симметрия — плохой архитектурный бог. При создании id почти всегда генерируется сервером. Если клиент присылает id, вы либо игнорируете его (и тогда зачем он вообще?), либо пытаетесь учесть (и тогда вы сами себе создаёте конфликтные сценарии). Гораздо яснее: id живёт в response, а не в create-request.
Ошибка №3: отсутствие узкого DTO для частичного обновления.
Если для обновления статуса вы используете «полный» DTO, вы вынуждаете клиента отправлять лишнее и рискуете перезатирать поля. Отдельный UpdateStatusRequest кажется маленькой деталью, но именно такие детали делают API «приятным», а не «лишь бы работало».
Ошибка №4: возврат «голого списка» без обёртки.
Сегодня кажется, что «зачем объект, если можно вернуть массив», а завтра вам нужно добавить count, и вы ломаете контракт. Ответ-обёртка (ReadingListResponse) — это маленькая привычка, которая быстро окупается. Даже в учебном проекте это делает контракт стабильнее и проще для клиента.
Ошибка №5: DTO как обычные mutable-классы со случайными сеттерами.
DTO — не доменная сущность, ему не нужны сеттеры и сложное поведение. Чем больше в DTO «жизни», тем выше шанс, что вы начнёте тянуть туда логику, валидацию и прочие обязанности, которые должны жить в других слоях. record дисциплинирует: это данные, и точка.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ