1. Спокуса одного TaskDto
Коли ви пишете перші ендпоїнти, ідея «один ресурс — один DTO» здається дуже логічною: у нас є задача (Task), отже, давайте зробимо один «публічний» клас TaskDto і житимемо спокійно. Це як купити один універсальний ключ, яким нібито можна відчинити всі двері в будинку: спочатку ви радієте, а потім раптом розумієте, що він відчиняє ще й ті двері, які краще було б не відчиняти (наприклад, у комірчину з вашим безладом).
Розділити вхід, вихід і внутрішню модель за ролями — це лише половина справи. Щойно ви доходите до конкретних ендпоїнтів, виявляється, що й самі DTO не можна робити універсальними: create, list, detail і update спілкуються з клієнтом по-різному.
Уявімо типовий «універсальний» DTO (анти-приклад):
package com.example.tasktracker.api.dto;
import java.util.List;
public class TaskDto {
// Антиприклад: один DTO намагається бути і request, і response одночасно
private String id; // серверне поле: клієнт не має надсилати його під час create
private String title; // клієнт вводить
private String description; // клієнт вводить
private String status; // зазвичай керується правилами домену/сервером
private List<String> tags; // клієнт вводить
// getters/setters
}
На перший погляд — усе красиво. І контролери виходять дуже «простими»:
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
@PostMapping
public TaskDto create(@RequestBody TaskDto request) {
return request; // поки «для прикладу»
}
}
Проблема в тому, що ця простота оманлива. TaskDto починає одночасно грати ролі, які заважають одна одній:
По-перше, у створення (create) і відповіді (response) різні очікування. Клієнт не має надсилати id — його створює сервер, — але DTO його містить. Клієнт також не має вільно надсилати status «як заманеться», але DTO його містить. Ви або починаєте мовчки ігнорувати ці поля, або, що гірше, випадково сприймаєте їх серйозно. І в обох випадках контракт уже стає розмитим: клієнт не розуміє, які поля справді працюють, а які — «як вийде».
По-друге, список (list) і детальна відповідь (detail) майже ніколи не збігаються за формою. Якщо GET /tasks повертає 100 задач, то 100 повних описів із тегами, описом і всім іншим — це не «зручно», а «вітаю, ви щойно зробили API, яке любить трафік більше, ніж користувачів».
По-третє, update стає логічною пасткою. Якщо TaskDto підходить і для читання, і для запису, то хто вирішує, які поля можна змінювати? Виходить, що форма даних у вас одна, а правила десь розмазані по коду (зазвичай по сервісу), і це погано читається.
Головна думка проста: один TaskDto приховує різницю між операціями. А якщо операція прихована, то прихований і контракт. А прихований контракт — це як прихований баг: він обов’язково проявиться, просто не сьогодні.
2. Create/update/response як різні договори
Коли ми проєктуємо DTO, ми насправді проєктуємо домовленість між клієнтом і сервером. І в різних операцій вона різна: під час створення клієнт повідомляє «ось дані, які я пропоную», під час оновлення — «ось нові значення для дозволених полів», а у відповіді сервер каже «ось як тепер виглядає ресурс за версією сервера». Якщо ми робимо один TaskDto, ми намагаємося втиснути три різні договори в один документ — і він стає юридично сумнівним, а іноді й небезпечним для вашого API.
Щоб відчути різницю, корисно дивитися на поля не як на «рядки в класі», а як на «хто ними керує».
Нижче — приблизна матриця для наших задач. Це не остаточна істина, а навчально-показовий спосіб побачити, що різні операції справді потребують різних форм даних:
| Поле | Хто «власник» | TaskCreateRequest | TaskPutRequest | TaskSummaryResponse | TaskDetailsResponse |
|---|---|---|---|---|---|
| id | сервер | — | — | ✓ | ✓ |
| title | клієнт | ✓ | ✓ | ✓ | ✓ |
| description | клієнт | ✓ | ✓ | — | ✓ |
| status | сервер/правила домену | — | — | ✓ | ✓ |
| tags | клієнт | ✓ | ✓ | — | ✓ |
Зверніть увагу: «—» тут не означає, що поля не існують. Вони існують у ресурсі, але не існують у конкретному контракті операції. І це нормально.
Тепер подивімося на DTO в коді. Модель створення має бути вузькою й описувати те, що клієнт реально вводить:
package com.example.tasktracker.api.dto.request;
import java.util.List;
public class TaskCreateRequest {
// Те, що клієнт реально надсилає під час створення задачі
private String title; // обов’язковість/валідність задається правилами API
private String description; // опис може бути необов’язковим — залежить від вимог
private List<String> tags; // теги — частина вхідних даних, якщо ми це дозволяємо
// getters/setters
}
Для оновлення (повного оновлення змінюваних полів) зручно мати окремий request DTO. Навіть якщо на старті він виглядає «як копія create», це не привід зливати їх в один клас: завтра в них з’являться різні обмеження і різні правила обов’язковості, і ви будете вдячні собі за розділення.
package com.example.tasktracker.api.dto.request;
import java.util.List;
public class TaskPutRequest {
// DTO для повного оновлення (PUT): клієнт надсилає нові значення дозволених полів
private String title; // можна змінювати, якщо це дозволено бізнес-правилами
private String description; // можна змінювати, якщо це дозволено бізнес-правилами
private List<String> tags; // можна змінювати, якщо це дозволено бізнес-правилами
// getters/setters
}
А детальна відповідь — це обіцянка сервера: що він покаже клієнту як представлення ресурсу:
package com.example.tasktracker.api.dto.response;
import java.util.List;
public class TaskDetailsResponse {
// Те, що сервер віддає назовні (read-only контракт для клієнта)
private String id; // сервер генерує, клієнту лише показуємо
private String title; // актуальний стан за версією сервера
private String description; // може бути відсутнім у summary, але є в details
private String status; // обчислюється/контролюється доменом
private List<String> tags; // детальне представлення може включати теги
// constructor/getters
}
Тепер — важливе спостереження, яке ламає «універсальний DTO»: напрямок даних різний. Request DTO йде всередину, response DTO йде назовні. Вони можуть бути схожими за полями, але сенс у них різний. Схожість — це випадковість домену, а не інженерна підстава їх об’єднувати.
Щоб закріпити думку, подивімося на JSON. Для створення клієнт надсилає мінімум:
{
"title": "Виправити помилку в продакшені",
"description": "NullPointerException у TaskController",
"tags": ["backend", "hotfix"]
}
А в детальній відповіді сервер повертає те, що є реальним станом ресурсу:
{
"id": "c4b0a7d9-8f2f-4e6a-9d7a-3eec6a5a8f1b",
"title": "Виправити помилку в продакшені",
"description": "NullPointerException у TaskController",
"status": "TODO",
"tags": ["backend", "hotfix"]
}
Якщо ви спробуєте зробити це одним TaskDto, ви або дозволите клієнту надсилати id/status, а потім «переграєте» їх на сервері, або почнете будувати дивні правила «це поле ігнорується під час create, але є обов’язковим у response». І це якраз той момент, коли DTO перестає бути контрактом і перетворюється на вгадування.
3. List vs detail: різні відповіді
Список ресурсів — це окремий тип контрактної задачі. Його майже завжди викликають частіше, він повертає більше даних і використовується в UI інакше: у списку людина зазвичай хоче побачити коротко й по суті, а в деталях — усе, що потрібно для роботи. Якщо ви робите один response DTO для списку і для деталей, ви майже гарантовано або перевантажуєте список, або збіднюєте деталі. Це як намагатися зробити один режим роботи для мікрохвильовки, духовки та чайника: «гріє ж, чого ви прискіпуєтеся».
Для списку в Task Tracker API нам підходить summary-представлення. Наприклад, у списку часто достатньо id, title і status:
package com.example.tasktracker.api.dto.response;
public class TaskSummaryResponse {
// Коротке представлення задачі для списків (щоб list не "роздувався")
private String id; // ідентифікатор задачі
private String title; // заголовок
private String status; // статус — корисний для відображення у списку
public TaskSummaryResponse(String id, String title, String status) {
this.id = id;
this.title = title;
this.status = status;
}
// getters
}
А для деталей ми залишаємо TaskDetailsResponse, який багатший:
package com.example.tasktracker.api.dto.response;
import java.util.List;
public class TaskDetailsResponse {
// Детальне представлення задачі для екрана "картки" / detail endpoint
private String id; // серверний id
private String title; // заголовок
private String description; // подробиці
private String status; // статус за версією домену
private List<String> tags; // теги, які в summary зазвичай не потрібні
// constructor/getters
}
Сигнатури контролерів одразу стають самодокументованими: навіть не читаючи тіло методу, видно, який контракт у операції:
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping
public List<TaskSummaryResponse> getTasks() {
return List.of(); // заглушка для прикладу
}
І окремо detail:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
return null; // заглушка для прикладу
}
Якби ми повертали один TaskDetailsResponse і в списку, і в деталях, у нас виникла б неприємна зв’язаність: будь-яке збагачення detail-відповіді автоматично збагачує і list-відповідь. Ви захотіли додати в деталі tags — і раптом список почав віддавати tags для кожної задачі. Потім ви захотіли додати в деталі ще кілька полів — і список став віддавати ще більше. І все це без жодного усвідомленого рішення «ми розширюємо контракт списку».
Дуже корисно тримати в голові просту картинку: ресурс один, представлень кілька.
flowchart TD
R["Task (ресурс)"]
S["TaskSummaryResponse
(для списків)"]
D["TaskDetailsResponse
(для деталей)"]
R --> S
R --> D
І знову закріпімо через JSON.
Список — приблизно так, без додаткових обгорток і метаданих: нам зараз важлива форма елемента.
[
{
"id": "1f1b4e7e-7b39-4b30-85a8-4cbf7a5a0a11",
"title": "Написати API-документацію",
"status": "IN_PROGRESS"
},
{
"id": "8aa3c2d1-9f2c-4c22-8b2d-34d9c9f2aa55",
"title": "Рефакторити шар DTO",
"status": "TODO"
}
]
Деталі по одній задачі:
{
"id": "8aa3c2d1-9f2c-4c22-8b2d-34d9c9f2aa55",
"title": "Рефакторити шар DTO",
"description": "Розділити DTO для create/list/detail/update",
"status": "TODO",
"tags": ["rest", "contract"]
}
У цьому й сенс: список — показати багато, але коротко; деталі — показати менше, але багатше.
4. Найменування DTO та структура пакетів
Назви DTO — це не косметика. Це ваш спосіб поставити паркан у голові розробника, включно з вами з майбутнього: який клас для чого. Якщо клас називається TaskDto, його хочеться використовувати всюди просто тому, що «підходить за назвою». Якщо клас називається TaskCreateRequest, то використовувати його як відповідь уже психологічно незручно: ви буквально повертаєте «Request» у response, і мозок починає підозрювати неладне — щонайменше, у рев’юера коду.
У нашому проєкті структура пакетів спеціально допомагає тримати ролі окремо. Request DTO живуть у api.dto.request, а response DTO — у api.dto.response. Це проста дисципліна, але вона працює як дорожня розмітка: коли її немає, усі їздять на відчуттях.
Зведена табличка за назвами — рівно для теми цієї лекції:
| DTO | Пакет | Роль в API |
|---|---|---|
| TaskCreateRequest | api.dto.request | вхідні дані для створення |
| TaskPutRequest | api.dto.request | вхідні дані для оновлення (не «відповідь сервера») |
| TaskSummaryResponse | api.dto.response | елемент списку задач |
| TaskDetailsResponse | api.dto.response | детальне представлення задачі |
У підсумку зі сигнатури методу контролера можна зрозуміти контракт майже як із заголовка статті. Порівняйте відчуття:
public TaskDto create(@RequestBody TaskDto dto) — незрозуміло, хто кому що винен.
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) — уже видно, де вхід, де вихід і що саме ми робимо.
І важливий момент про те, щоб не роздувати зоопарк: різні DTO не означають, що вам потрібно робити новий клас на кожну дрібницю. Здоровий мінімум для CRUD-подібного ресурсу майже завжди виглядає як create request + update request + summary response + details response. Це не бюрократія, а нормальна декомпозиція контрактів.
5. Наскрізний приклад у контролері
Тепер давайте поєднаємо це з проєктом на рівні самих контрактів контролера. Поки нам важливо побачити, які DTO заходять у метод і які виходять назовні. Саме перетворення між цими формами та внутрішнім Task винесемо за дужки, щоб не змішувати дві різні задачі: вибір DTO за операціями та ручний mapping.
Список задач віддаємо як список summary DTO — уже за однією сигнатурою видно, що list endpoint не обіцяє повну detail-відповідь:
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping
public List<TaskSummaryResponse> getTasks() {
return List.of(); // заглушка для прикладу
}
Деталі по одній задачі — details DTO:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
return null; // заглушка для прикладу
}
Створення приймає create request і повертає details response — вхід і вихід тут уже спеціально несиметричні:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping
public TaskDetailsResponse createTask(@RequestBody TaskCreateRequest request) {
return null; // заглушка для прикладу
}
І навіть якщо update endpoint ще не реалізовано просто зараз, його сигнатура як контракту вже зрозуміла — і це головний сенс розділення DTO:
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PutMapping("/{taskId}")
public TaskDetailsResponse updateTask(
@PathVariable String taskId,
@RequestBody TaskPutRequest request
) {
return null; // заглушка для прикладу
}
Зверніть увагу, як тут читається контракт без знання внутрішньої моделі Task. Клієнту не потрібно знати, які поля всередині сервісу, як влаштований репозиторій, де seed data, які там колекції та мапи. Клієнт бачить лише те, що ми обіцяємо: конкретну форму входу та виходу.
На схемі нижче Mapper — це поки просто назва для точки перетворення між зовнішнім контрактом і внутрішнім Task; деталі цього перетворення нас зараз не цікавлять.
sequenceDiagram
participant C as Клієнт
participant CTR as Контролер
participant M as Мапер
participant SVC as Сервіс
C->>CTR: TaskCreateRequest (JSON)
CTR->>M: DTO запиту
M-->>CTR: дані для домену
CTR->>SVC: create(...)
SVC-->>CTR: Task (внутрішня модель)
CTR->>M: Task
M-->>CTR: TaskDetailsResponse
CTR-->>C: TaskDetailsResponse (JSON)
І ось тут окремі DTO раптом стають не «зайвими класами», а способом тримати API у формі: клієнт працює з контрактом, сервіс — з внутрішньою моделлю, а перехід між ними керований і зрозумілий.
6. Типові помилки з одним DTO
Коли починаєте розділяти DTO за операціями, легко потрапити в кілька поширених пасток: частина — зі звички «спростити», частина — з бажання передбачити все одразу. Іронія в тому, що найчастіше «спростити» закінчується ускладненням, просто воно відкладається на тиждень і стає проблемою не «зроби зараз», а «розбирайся потім».
Помилка №1: робити один TaskDto, а відмінності операцій компенсувати коментарями та домовленостями в голові.
Проблема такого підходу в тому, що домовленості в голові не компілюються. За кілька днів ви самі забудете, які поля ігноруємо під час create, а новий розробник забуде це за кілька хвилин. DTO мають бути самодокументованими: create request не має містити поля відповіді просто тому, що «ну вони ж є у задачі».
Помилка №2: повертати TaskDetailsResponse у списку, бо «так менше класів».
Це виглядає як економія, доки в detail-представлення не з’являється зайве поле. Щойно detail розширюється, list стає важчим і змінюється контракт, причому часто непомітно для команди. Summary DTO — це не примха, а спосіб тримати список коротким і незалежним.
Помилка №3: використовувати TaskCreateRequest і для update, і для create, бо «поля однакові».
Навіть якщо сьогодні поля однакові, сенс операцій різний. У create та update зазвичай різні вимоги до обов’язковості та різні обмеження. Якщо ви наперед злили DTO, то завтра отримаєте конфлікт: потрібно змінити одне, а ламається інше. Розділення «на виріст» тут виправдане й при цьому недороге.
Помилка №4: назвати все надто абстрактно (TaskRequest, TaskResponse, TaskDto).
Абстрактні імена звучать «універсально», але саме це й погано. Із назви має бути видно призначення: TaskCreateRequest і TaskPutRequest — це різні речі. TaskSummaryResponse і TaskDetailsResponse — це різні речі. Чим точніше ім’я, тим менший шанс, що клас почнуть застосовувати не туди.
Помилка №5: намагатися зробити DTO «універсальним» через купу nullable-полів.
Дуже частий запах коду: «нехай усі поля будуть nullable, а потім розберемося». У підсумку JSON стає непередбачуваним, код — повним if (x != null), а контракт — неясним. DTO за операціями якраз і потрібні, щоб не грати у вгадування: а це поле зараз має бути чи ні?
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ