1. DTO-контур: «словник проєкту»
Коли ви вперше проєктуєте DTO, дуже хочеться зробити «табличку полів» і на цьому заспокоїтися. Але в реальному проєкті цінність не в тому, що ви намалювали 5 класів, а в тому, що ви зібрали контур: невеликий набір DTO навколо конкретного сценарію. Контур — це ніби «набір слів» для розмови: якщо слова підібрані правильно, спілкуватися легко; якщо ні — починаються пояснення на пальцях і переклад із «людської» на «внутрішню мову сервера».
У ReadLater Starter у нас є два великі напрями обміну даними, і це важливо тримати в голові вже зараз. На першому етапі ми працюємо із зовнішнім каталогом: там є пошук і детальна картка книги. Це чужий контракт, який ми будемо читати й перекладати на свої потреби. На другому етапі ми піднімаємо локальний API для списку читання: там є створення та читання списку, і це вже наш контракт, за який ми відповідаємо. Тому сьогодні ми проєктуємо DTO так, щоб вони не заважали одне одному й водночас говорили однією мовою домену ReadLater.
Щоб не загубитися, корисно уявити це так:
flowchart TD
subgraph DTOs["DTO-контури"]
A["DTO пошуку в каталозі"]
B["DTO деталей каталогу"]
C["DTO створення/оновлення списку читання"]
D["DTO читання списку читання"]
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 для списку читання.
Якщо перевести цю форму в 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": "Підручник із майстерності гнучкої розробки програмного забезпечення"
}
Тут видно ключову ідею: у 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: створення та відповідь
З 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 {
// Локальний ідентифікатор елемента списку читання (генерується сервером)
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: оновлення та статус
У сценаріях оновлення новачків найчастіше підстерігає пастка: «Ну, якщо в нас є 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 {
// Список елементів списку читання (кожен елемент — повноцінний ReadingItemResponse)
public ReadingItemResponse[] items;
// Кількість елементів у items у поточному контракті
public int count;
}
І тут важливо не переплутати зміст count. У простому варіанті це кількість елементів у items. Навіть якщо в майбутньому у вас з’являться фільтрація або обмеження, цей DTO все одно залишиться корисним: клієнту простіше жити, коли форма відповіді однакова завжди.
7. Єдиний словник назв полів
На цьому місці важливо не вигадувати нову мову для кожної кінцевої точки. Якщо 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 або проєктуєте кінцеву точку, відразу видно, який 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) і тримайте його. Послідовність нудна, зате надійна.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ