1. DTO-контур: «словарь проекта»
Когда вы впервые проектируете DTO, очень хочется сделать «табличку полей» и на этом успокоиться. Но в реальном проекте ценность не в том, что вы нарисовали 5 классов, а в том, что вы собрали контур: небольшой набор DTO вокруг конкретного сценария. Контур — это как «набор слов» для разговора: если слова подобраны правильно, общаться легко, если нет — начинаются объяснения на пальцах и перевод с «человеческого» на «внутренний язык сервера».
В ReadLater Starter у нас два крупных направления обмена данными, и это важно держать в голове уже сейчас. В первой фазе мы работаем с внешним каталогом: там есть поиск и детальная карточка книги, и это чужой контракт, который мы будем читать и «переводить». Во второй фазе мы поднимаем локальный API для списка чтения: там есть создание и чтение списка, и это уже наш контракт, за который мы отвечаем. Поэтому сегодня мы проектируем DTO так, чтобы они не мешали друг другу и при этом говорили на одном языке домена ReadLater.
Чтобы не потеряться, полезно представить это так:
flowchart TD
subgraph DTOs["DTO-контуры"]
A["Catalog Search DTOs"]
B["Catalog Details DTOs"]
C["Reading List Create/Update DTOs"]
D["Reading List Read DTOs"]
end
flowchart TD User["Пользователь"] -->|команда| App["ReadLater Starter (приложение)"] App -->|HTTP + JSON| Catalog["Внешний каталог книг"] User -->|HTTP + JSON| LocalApi["Локальный ReadLater API (будущий)"]
Здесь главное — не точность схемы, а мысль: DTO возникают там, где есть граница общения. Чем яснее граница, тем меньше хаоса в коде позже.
2. DTO для catalog search: карточка и список
Сценарий поиска по каталогу — классический пример, где «жадность» в DTO особенно опасна. Очень легко попытаться тащить в результат поиска всё: описание, год, язык, ISBN, список издателей… а потом понять, что пользователю в выдаче нужны всего три вещи: идентификатор, название и автор. Поэтому для поиска мы сознательно делаем короткую карточку результата и обёртку ответа со списком и count. Это дисциплина контракта: выдача должна быть лёгкой и предсказуемой.
С точки зрения JSON-контракта (неважно, откуда он придёт — из внешнего API или из нашего нормализованного слоя), нам хочется получить примерно такую форму:
{
"items": [
{
"externalId": "OL12345M",
"title": "Clean Code",
"author": "Robert C. Martin"
}
],
"count": 1
}
Обратите внимание на несколько мелочей, которые кажутся скучными, но потом экономят нервы. Во-первых, items — это всегда массив, даже если результат один. Во-вторых, count помогает клиенту не гадать «это полный список или часть». В-третьих, externalId — это явно внешний идентификатор, а не наш будущий id для reading list.
Если перевести эту форму в DTO, получится примерно так:
package com.example.readlater.catalog.dto;
public class CatalogSearchItemResponse {
// Идентификатор книги во внешнем каталоге (не наш локальный id)
public String externalId;
// Название книги для карточки в выдаче
public String title;
// Автор в упрощённом виде одной строкой (как приходит/как мы нормализуем)
public String author;
}
И обёртку:
package com.example.readlater.catalog.dto;
public class CatalogSearchResponse {
// Список кратких карточек результата поиска
public CatalogSearchItemResponse[] items;
// Количество элементов в выдаче (в простом варианте совпадает с items.length)
public int count;
}
Здесь важен сам контракт: поиск возвращает короткие карточки в обёртке items + count. Конкретная Java-форма вторична; принцип в том, что список — это отдельный ответ со своими метаданными.
3. DTO для catalog details: детальная модель
С детальной карточкой книги всё наоборот: если в поиске мы боролись с желанием тащить «всё», то в details нам нужно признать, что детали действительно богаче. И это нормальная асимметрия. Пытаться использовать search-DTO как details-DTO — как пытаться использовать визитку вместо паспорта: вроде тоже бумажка с текстом, но задачам не соответствует.
JSON-форма детальной карточки может быть такой (опять же, это наш «нормализованный взгляд», а не клятва кровью, что внешний провайдер пришлёт ровно так):
{
"externalId": "OL12345M",
"title": "Clean Code",
"author": "Robert C. Martin",
"description": "A Handbook of Agile Software Craftsmanship"
}
Здесь видно ключевую идею: у details появляется description, которого не было в search. И это не «непоследовательность», а честное отражение сценария.
DTO для этого:
package com.example.readlater.catalog.dto;
public class CatalogBookDetailsResponse {
// Идентификатор книги во внешнем каталоге
public String externalId;
// Название книги
public String title;
// Автор (строкой, в упрощённом виде)
public String author;
// Описание: поле есть именно в details-сценарии, а не "иногда везде"
public String description;
}
Почему это отдельный класс, а не расширение search-DTO? Потому что расширение быстро приводит к мутанту: «в search приходит description, но иногда null, а иногда отсутствует, а иногда пустая строка…». Отдельный DTO делает правила проще: в search description нет вообще; в details — есть (пусть даже как опциональное поле, но в рамках details-контракта).
И ещё один важный момент. Наличие этих DTO — это уже список вопросов к провайдеру: «Где у тебя id? Как у тебя называется title? Как выглядит author? Есть ли description и где оно лежит?» DTO становятся вашей шпаргалкой по контракту.
4. DTO локального API: create и response
С catalog-контуром задача была такая: аккуратно нормализовать чужой контракт под свои сценарии поиска и деталей. В локальном API ситуация другая — здесь уже мы сами задаём правила, и поэтому особенно важно честно развести request и response.
Теперь переключимся на будущую серверную фазу, где ReadLater Starter станет локальным HTTP API. Мы ещё не пишем сервер, но мы уже можем (и должны) спроектировать контракты. Самый показательный сценарий здесь — создание элемента списка чтения. Он отлично демонстрирует разницу между request и response: клиент присылает данные, а сервер добавляет идентификатор и возвращает итоговый ресурс.
Контракт создания (условно POST /api/v1/reading-list) выглядит так:
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
Тут два поля потенциально необязательные: externalId и comment. Их можно не присылать, если пользователь добавляет книгу вручную, не привязываясь к каталогу, или если он просто не хочет писать комментарий. Это хороший пример того, как DTO отражает реальную свободу сценария.
DTO запроса:
package com.example.readlater.readinglist.dto;
public class CreateReadingItemRequest {
// Название книги (то, что вводит пользователь или что мы берем из каталога)
public String title;
// Автор книги (упрощённо одной строкой)
public String author;
// Статус чтения из фиксированного набора значений: PLANNED / IN_PROGRESS / FINISHED
public String status;
// Опционально: ссылка на книгу во внешнем каталоге (может отсутствовать)
public String externalId;
// Опционально: комментарий пользователя (может отсутствовать)
public String comment;
}
Теперь ответ. Сервер создаёт ресурс и возвращает его, уже с id:
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
DTO ответа:
package com.example.readlater.readinglist.dto;
public class ReadingItemResponse {
// Локальный идентификатор элемента reading list (генерируется сервером)
public long id;
// Название книги
public String title;
// Автор книги
public String author;
// Текущий статус чтения из того же фиксированного набора значений
public String status;
// Идентификатор во внешнем каталоге (может быть null/отсутствовать по правилам сериализации)
public String externalId;
// Пользовательский комментарий (может быть null/отсутствовать)
public String comment;
}
И вот тут есть прям «проверка на адекватность контракта». Если вам вдруг захотелось добавить id в CreateReadingItemRequest, задайте себе вопрос: «А кто создаёт идентификатор — клиент или сервер?» В нашей истории сервер. Значит, id в create-request не нужен.
5. DTO для PUT и PATCH: update и статус
В update-сценариях новичков чаще всего поджидает ловушка «ну раз у нас есть UpdateReadingItemRequest, то UpdateStatusRequest не нужен». Это звучит логично, пока не вспомнить человеческую реальность: иногда мы хотим поменять статус быстро, не трогая остальные поля. И если для этого нужно каждый раз отправлять «весь объект целиком», API становится неудобным и легко ломается случайными данными.
Полное обновление (условно PUT /api/v1/reading-list/{id}) обычно использует request DTO, который по полям похож на create (да, похоже, и это нормально):
package com.example.readlater.readinglist.dto;
public class UpdateReadingItemRequest {
// Новое название книги
public String title;
// Новый автор
public String author;
// Новый статус из того же фиксированного набора (полное обновление подразумевает, что вы присылаете всё целиком)
public String status;
// Опциональная привязка к внешнему каталогу
public String externalId;
// Опциональный комментарий пользователя
public String comment;
}
Обратите внимание: id снова не в body. В API мирный договор такой: id живёт в path, а body — это новые данные ресурса.
Теперь частичное обновление статуса (условно PATCH /api/v1/reading-list/{id}/status) — это отдельный маленький запрос:
{
"status": "FINISHED"
}
DTO:
package com.example.readlater.readinglist.dto;
public class UpdateStatusRequest {
// Меняем только статус из того же фиксированного набора — остальные поля не трогаем
public String status;
}
Польза от такого DTO не только в удобстве. Он ещё и делает контракт честным: когда клиент отправляет UpdateStatusRequest, сервер понимает, что это изменение статуса, а не «попытка обновить всё, но забыли половину полей». И у вас меньше шансов получить «магическое поведение», где PUT внезапно превращается в частичное обновление, потому что поля пришли null (а вы потом сидите и думаете, кто вам затёр автора).
6. DTO для чтения: элемент и список
Чтение — это та часть API, где многие поначалу ленятся и возвращают «просто массив» или «просто объект». Оно работает… до первого расширения. Как только рядом со списком нужно вернуть count, фильтры, какую-то мета-информацию или хотя бы стабильную форму ответа, оказывается, что «просто массив» был короткой дорогой в длинный рефакторинг. Поэтому для списка мы заранее делаем обёртку.
Один элемент (условно GET /api/v1/reading-list/{id}) возвращает тот же ReadingItemResponse, что и после создания. Это хороший признак: клиент видит одну и ту же форму ресурса и сразу после POST, и при обычном GET.
А список (условно GET /api/v1/reading-list) возвращает обёртку:
{
"items": [
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Найти бумажное издание"
}
],
"count": 1
}
DTO обёртки:
package com.example.readlater.readinglist.dto;
public class ReadingListResponse {
// Список элементов reading list (каждый элемент — полноценный ReadingItemResponse)
public ReadingItemResponse[] items;
// Количество элементов в items в текущем контракте
public int count;
}
И здесь важно не перепутать смысл count. В простом варианте это «количество элементов в items». Даже если в будущем у вас появится фильтрация или лимиты, этот DTO всё равно останется полезным: клиенту проще жить, когда форма ответа одинакова всегда.
7. Единый словарь имён полей
На этом месте важно не изобретать новый язык для каждого endpoint. Если externalId означает связь с внешним каталогом, то он так и называется и в catalog DTO, и в reading list DTO. Если списочный ответ построен вокруг items и count, эта пара не должна внезапно превращаться в data и totalCount.
Достаточно держать несколько опорных слов без дрейфа:
- id — локальный идентификатор элемента reading list;
- externalId — идентификатор книги во внешнем каталоге;
- title, author — базовые данные книги;
- status, comment — поля локального reading list;
- items, count — форма списочного ответа.
Этого словаря уже достаточно, чтобы catalog-контур и локальный API не разговаривали на двух разных диалектах.
8. Карта DTO проекта
Когда DTO становится много, у новичка появляется тревога: «А не перебор ли? Может, я слишком усложняю?» На самом деле, если каждый DTO имеет ясную роль, это не усложнение, а упрощение. Вы убираете неоднозначность. Чтобы это увидеть, полезно держать маленькую карту.
Вот «карта» наших DTO-контуров в одном месте:
| Сценарий | Request DTO | Response DTO |
|---|---|---|
| catalog search | (внутренний критерий поиска, в JSON форме не обязателен) | CatalogSearchResponse (items + count) |
| catalog details | — | CatalogBookDetailsResponse |
| POST /reading-list | CreateReadingItemRequest | ReadingItemResponse |
| PUT /reading-list/{id} | UpdateReadingItemRequest | ReadingItemResponse |
| PATCH /reading-list/{id}/status | UpdateStatusRequest | ReadingItemResponse (или тот же) |
| GET /reading-list/{id} | — | ReadingItemResponse |
| GET /reading-list | — | ReadingListResponse (items + count) |
И это выглядит ровно так, как должна выглядеть взрослая система: разные сценарии — разные формы данных. Если два DTO случайно совпали по полям, это не повод их склеивать. Это повод порадоваться, что сценарии похожи. Но если вы склеите их, то позже любое небольшое отличие заставит вас либо делать поля «на всякий случай», либо городить null и «не заполняется в таком-то сценарии». А «поле не заполняется в таком-то сценарии» — это всегда будущая путаница.
Если хочется ещё более простого правила, то оно такое: DTO — это как форма на сайте. Форма «регистрация» и форма «смена пароля» могут выглядеть почти одинаково, но никто в здравом уме не делает одну форму «на всё», где половина полей то нужна, то не нужна, то «если вы пришли со страницы X — заполните только вот это». DTO работают так же.
Такая карта уже работает как чек-лист: когда вы смотрите на конкретный JSON или проектируете endpoint, сразу видно, какой DTO нужен и где нельзя склеивать разные роли в один класс.
9. Типичные ошибки в DTO-контуре
В конце дня обычно выясняется, что ошибок делают не «в синтаксисе», а в голове: когда DTO начинают выполнять не свою работу. Это нормально — мы как раз учимся отличать роли. Ниже несколько типовых грабель, которые встречаются почти у всех (и да, я наступал на них тоже, иначе как бы я так уверенно их перечислял).
Ошибка №1: «Один универсальный DTO на всё, чтобы не плодить классы».
Поначалу кажется, что это экономия. На практике это экономия только на количестве файлов, но не на сложности. Универсальный DTO быстро превращается в «мешок полей», где query соседствует с id, а count — со status. В результате чтение кода становится как чтение инструкций к микроволновке: вроде слова знакомые, но почему тут написано про гриль, если я хотел разогреть чай.
Ошибка №2: добавлять id в create-request «по инерции».
Это случается автоматически: раз в ответе есть id, значит, он «должен быть и во входе». Но в нашем сценарии id создаёт сервер. Если клиент присылает id, возникает вопрос: это он просит создать элемент с конкретным идентификатором? Зачем? А что делать при конфликте? Поэтому лучше сразу держать правило: create-request не приносит серверный id.
Ошибка №3: использовать search-DTO как details-DTO, а потом лечить это null-ами.
Если вы используете один DTO и для поиска, и для деталей, вы почти неизбежно получите набор полей «иногда пусто». Потом появятся условности: «в поиске description null, но в деталях заполнено», «в поиске author иногда массив, но мы берём первого», и так далее. Отдельный DTO для details делает правила проще и понятнее.
Ошибка №4: возвращать «просто массив» для списка и потом страдать при первом расширении.
Сегодня «просто массив» кажется удобным. Завтра вы захотите count. Послезавтра — фильтрацию и понятную форму пустого ответа. И вы внезапно обнаружите, что меняете контракт. Обёртка ReadingListResponse { items, count } выглядит чуть более многословно, но это инвестиция в стабильность формы.
Ошибка №5: смешивать стили имён полей и надеяться, что «и так понятно».
Смешение external_id, externalId и externalID в одном проекте — это не стиль, это мини-лотерея. Клиент вынужден запоминать разные имена для одной сущности, а вы — постоянно проверять, «как там было в этом эндпоинте». Выберите один стиль (в нашем случае camelCase) и держите его. Последовательность скучна, зато надёжна.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ