1. Система response DTO замість розсипу
Якщо ви колись бачили проєкт, де відповіді API нагадують набір випадкових «то так, то сяк», ви знаєте це відчуття: ніби потрапили до квартири, де в кожного чайника свій стандарт розеток. З одного боку, все наче працює. З іншого — жити в такому страшно, та й гостей краще не кликати. В API та сама історія: поки у вас два ендпоінти, здається, що все гаразд, але щойно зʼявляються нові поля, нові представлення й нові сценарії, відсутність єдиного підходу перетворює проєкт на пазл без картинки на коробці.
Системний підхід до response DTO розв’язує одразу кілька прикладних проблем. По‑перше, клієнту простіше: він швидко вчиться розпізнавати «зведення», «деталі» та «список» за одним і тим самим патерном. По‑друге, вам простіше розвивати контракт: ви заздалегідь фіксуєте, що список — це завжди envelope, деталі — окрема форма, а поля називаються однаково всюди, де мають той самий зміст. По‑третє, контролери й мапери перестають бути місцем, де «на швидку руку» вирішують, як сьогодні має виглядати JSON.
Далі ми закріпимо для Task Tracker API три опорні точки:
- Summary: компактна модель для списку.
- Details: більш повна модель для детального перегляду (і зазвичай для create/update-відповідей, якщо ви віддаєте тіло).
- 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 виглядає банально, але це якраз серверне значення, задане за замовчуванням, яке краще віддати явно. Клієнту не потрібно будувати здогадки «а що означає відсутність поля?» — він просто бачить стан.
{
"id": "t1",
"title": "Написати документацію",
"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": "Написати документацію",
"description": "Підготувати API-документацію",
"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": "Написати документацію",
"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", "Написати документацію", TaskStatus.TODO, TaskPriority.HIGH, false)
);
// Важливо: навіть при одному елементі повертаємо об'єкт, а не “голий масив”.
return new PagedResponse<>(items, 0, 20, items.size(), 1, "updatedAt,desc");
}
}
Тут елементи вже показані з реальними TaskStatus і TaskPriority, щоб shape не розвалювався навіть у схематичному прикладі. У реальному коді items усе одно приходитимуть через сервіс + мапер. Головне, що потрібно побачити: корінь відповіді — обʼєкт, і в ньому є items. Навіть якщо задач поки що одна, і здається, що масив був би коротшим, «коротше» — це задоволення на п’ять хвилин, а контракт — це звичка на місяці.
Приклад: 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,
"Написати документацію",
null, // description може бути null за контрактом: “опису немає”
TaskStatus.TODO,
TaskPriority.HIGH,
List.of(), // tags краще віддавати [] (а не null)
Instant.parse("2026-03-21T10:15:30Z"),
false
);
}
}
Знову: приклад «скелетний», але він показує ідею. Деталка — це окремий DTO, а не «ті самі елементи, що у списку, тільки випадково більше полів».
Нижче — невелика схема, щоб у голові «клацнуло»:
flowchart TD
Client[HTTP-клієнт] -->|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-коду це може виглядати як безпечний рефакторинг, але для клієнта це зміна, що ламає сумісність. Якщо вже дуже хочеться змінити імʼя — це окреме контрактне рішення, а не «давайте поправимо неймінг».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ