1. DTO: визначення і роль
Якщо до цього моменту JSON для вас був чимось на кшталт «файла з полями», то сьогодні ми зробимо крок до більш дорослого погляду. DTO — це спосіб перетворити зовнішню форму даних на зрозумілий, стабільний об’єкт у коді. Головне тут — не синтаксис Java і навіть не JSON, а дисципліна: які дані мають право входити в застосунок, а які мають право виходити назовні.
DTO (Data Transfer Object) — це об’єкт, який описує форму даних на межі застосунку. У нашому курсі межа зазвичай виглядає так: з одного боку — клієнт (Postman, інший сервіс, майбутній UI), з іншого — наш застосунок ReadLater Starter. Клієнт спілкується з нами мовою HTTP + JSON, а ми всередині коду хочемо оперувати не «сирими рядками», а зрозумілими структурами.
Важливо одразу зняти одне популярне хибне уявлення: DTO — це не «об’єкт застосунку взагалі» і не «головна сутність проєкту». DTO — це прикордонник. Його завдання — суворо й явно описати, які поля і в якому вигляді ми очікуємо побачити на вході або обіцяємо повернути на виході.
Щоб це краще відчути, корисно подивитися на схему, навіть якщо ми поки нічого не реалізуємо:
flowchart TD
C["Клієнт / Postman"] -->|Request JSON| RDTO["Request DTO"]
RDTO --> APP["Прикладна логіка застосунку"]
APP --> SDTO["Response DTO"]
SDTO -->|Response JSON| C
Ця схема не про те, як це написати в Java (це буде пізніше), а про сенс: на вході й на виході в нас різні задачі, тому й DTO зазвичай різні.
2. Request DTO: вхідні дані
Request DTO — це місце, де ми фіксуємо «правила входу до нашого клубу». Вхідний JSON має відповідати на запитання: що клієнт просить зробити і які дані він для цього надає. Тут легко скотитися в крайність «нехай надсилають усе, а ми розберемося», але це як відчинити двері під’їзду всім підряд: жити стане веселіше, але ненадовго.
Request DTO — це DTO, який відповідає вхідному JSON (або, ширше, вхідним даним запиту). Він відповідає на запитання: «Що клієнт має передати, щоб сценарій мав сенс?».
Наприклад, у майбутньому в нас буде сценарій «додати книгу до списку читання». Клієнт хоче створити елемент ReadingListItem. Що він може надіслати? Зазвичай це поля, які задає користувач, а не сервер. Тобто назва, автор, статус, а також необов’язкові речі на кшталт коментаря.
І ось тут з’являється перший важливий момент лекції: поле id майже ніколи не є частиною create-запиту. Чому? Бо id зазвичай створює сервер. Якщо клієнт надсилатиме id, у нас миттєво виникає запитання: «А якщо він надішле id = 1 — це він створює новий об’єкт чи намагається перезаписати чужий?» Навіть у навчальному проєкті такі запитання краще знімати архітектурно, а не героїчно “розрулювати потім”.
Міні-приклад вхідного JSON для створення (це саме request):
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Знайти паперове видання"
}
Якщо перекласти це на Java-мову (чисто як форму даних, без логіки), request DTO може виглядати так:
package com.example.readlater.readinglist.dto;
// DTO для вхідного запиту на створення елемента списку читання.
// Важливо: тут немає id, тому що id генерує сервер.
public record CreateReadingItemRequest(
String title,
String author,
String status,
String externalId,
String comment
) {}
Зверніть увагу: це не «сутність у базі», не «внутрішній об’єкт застосунку», а просто контейнер вхідних даних. Він зручний тим, що його легко читати, легко обговорювати як контракт і важко переплутати з тим, що ми повертаємо назад.
3. Response DTO: вихідні дані
Response DTO — це наша «квитанція» клієнту. Він має відповідати на запитання: що вийшло в результаті. І якщо request DTO — це радше «побажання клієнта», то response DTO — це обіцянка сервера. Клієнт спиратиметься на нього у своєму коді, у перевірках, у UI — та хоч би й у душевній рівновазі.
Response DTO — це DTO, який відповідає вихідному JSON. Він відповідає на запитання: «Що клієнт отримає назад, якщо все пройшло успішно?».
Візьмімо той самий сценарій створення елемента reading list. Клієнт надіслав дані — сервер створив об’єкт. Тепер сервер може повернути створений ресурс, і тут id уже з’являється легально й логічно. Бо тепер це не “бажання клієнта”, а результат роботи сервера.
Приклад response JSON після успішного створення:
{
"id": 42,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Знайти паперове видання"
}
Response DTO під цю відповідь (знову ж таки: просто форма даних):
package com.example.readlater.readinglist.dto;
// DTO для відповіді клієнту: те, що сервер обіцяє повернути після успішного створення.
// Тут id уже обов’язковий, тому що це результат роботи сервера.
public record ReadingItemResponse(
long id,
String title,
String author,
String status,
String externalId,
String comment
) {}
Поки ми не обговорюємо, як саме це стане JSON (це окрема тема). Зараз важливо закріпити думку: request і response часто схожі, але в них різна роль. І через цю роль вони мають повне право відрізнятися за полями, за обов’язковістю, за структурою і навіть за іменами.
4. Відмінності request/response: власник поля
Найчастіша причина плутанини в новачка виглядає так: «Ну там і там поля, ну зроблю один клас і не мучитимусь». І в цей момент інженерна частина реальності тихо дістає блокнот і записує ваше ім’я. Бо «один клас на все» часто перетворює проєкт на звалище null-ів, дивних перевірок і майбутніх “гарячих виправлень”.
Є простий спосіб мислити правильно: ставте собі запитання «Хто володіє цим полем?». Тобто хто має право його задавати й змінювати: клієнт чи сервер.
Подивімося на це на прикладі create-сценарію для ReadLater Starter:
| Поле | Хто задає | Де з’являється природно |
|---|---|---|
| title | клієнт | request і response |
| author | клієнт | request і response |
| status | клієнт | request і response |
| externalId | клієнт | request і response (якщо надіслав) |
| comment | клієнт | request і response (якщо надіслав) |
| id | сервер | тільки response |
І вже з цієї таблиці видно, чому «один DTO на все» починає ламатися. Якщо ви все ж зробите універсальний DTO, вам доведеться або дозволяти клієнту надсилати id (що методично шкідливо), або робити id nullable і писати купу перевірок (що теж методично шкідливо, бо це не «складність домену», а «складність через неохайний дизайн»).
Ще одна важлива причина відмінностей — це те, що request і response відповідають на різні запитання:
- Request відповідає: «Що потрібно зробити?»
- Response відповідає: «Що вийшло?»
Іноді ці відповіді справді схожі, але симетрія тут не обов’язкова. Наприклад, запит на пошук може бути маленьким (лише query), а відповідь — великою (список знайдених книг). І це нормально: запит — це інструкція, відповідь — це результат.
5. Приклади: пари DTO
Зараз найкорисніше — не намагатися “одразу придумати всі DTO проєкту”, а зробити пару зрозумілих прикладів, щоб мозок перестав тягнутися до універсального «на все один клас». Ми візьмемо два сценарії, які добре демонструють асиметрію: створення елемента списку читання і пошук у каталозі. Це ті випадки, де request і response за змістом різні, навіть якщо слова схожі.
Create: request без id, response з id
Create — класичний приклад того, що request і response мають різні обов’язки. Клієнт повідомляє серверу дані, а сервер повертає підтвердження результату. У нашому проєкті це означає: клієнт просить додати книгу до reading list, сервер генерує ідентифікатор і повертає створений об’єкт.
Request DTO (ми вже бачили, але закріпімо як пару):
package com.example.readlater.readinglist.dto;
public record CreateReadingItemRequest(
String title,
String author,
String status,
String externalId,
String comment
) {}
Response DTO:
package com.example.readlater.readinglist.dto;
public record ReadingItemResponse(
long id,
String title,
String author,
String status,
String externalId,
String comment
) {}
Сенс пари простий: request описує «дані для створення», response — «створений результат».
Частковий сценарій: змінити статус
Навіть без майбутнього REST-дизайну вже видно, що request DTO можуть бути вузькими. Іноді клієнт хоче змінити лише одне поле. Тоді request “на все” перетворюється на джерело помилок: клієнт випадково надішле старий title, старий author, і ви в якийсь момент почнете оновлювати не те.
Тому окремий request DTO під конкретну дію — це нормально:
package com.example.readlater.readinglist.dto;
// DTO для вузького сценарію: змінюємо лише статус, не чіпаємо інші поля.
public record UpdateStatusRequest(String status) {}
І тут знову видно, чому request і response — різні істоти. Request вузький і відповідає на запитання «що змінити», response може повернути повний стан елемента (наприклад, щоб клієнт одразу побачив підсумок). Не тому, що «так прийнято», а тому, що це зручно й чесно відображає ролі сторін.
Пошук: критерії та результати
Сценарій пошуку чудово ламає ілюзію симетрії. У пошуку запит — це критерії («що шукати»), а відповідь — дані («що знайшли»). Вони взагалі з різних світів, навіть якщо ми обидва рази говоримо «DTO».
Request DTO для пошукового сценарію всередині нашого застосунку може бути таким:
package com.example.readlater.catalog.dto;
// DTO критеріїв пошуку: що шукати і скільки результатів повернути.
public record CatalogSearchRequest(String query, int limit) {}
А один елемент відповіді (коротка картка книги) може бути таким:
package com.example.readlater.catalog.dto;
// DTO елемента відповіді: коротка картка знайденої книги (те, що показуємо клієнту).
public record CatalogSearchItemResponse(
String externalId,
String title,
String author
) {}
Зверніть увагу, наскільки це асиметрично: request нічого не знає про externalId знайдених книг, бо він просить. Response, навпаки, зазвичай нічого не знає про query, бо він відповідає результатом.
6. «Універсальний DTO» і Франкенштейн
Іноді універсальний DTO здається гарною ідеєю, бо «класів менше». На практиці це економія рівня «не купуй шафу, складай усе на стілець — стілець же вже є». У перші два дні ви навіть будете задоволені, а потім у вас буде стілець, який одночасно шафа, вішалка, робоче місце і причина нервового тика.
Ось типовий приклад такого «універсального щастя»:
package com.example.readlater.badideas;
// Приклад анти-патерну: DTO намагається бути і запитом, і відповіддю, і моделлю "про всяк випадок".
// Зазвичай це призводить до null-полів, плутанини в контракті та неявних правил використання.
public record UniversalBookDto(
Long id,
String query,
String title,
String author,
String status,
Integer count
) {}
Чому це погано саме в контексті request/response?
Бо цей об’єкт намагається обслуговувати одразу кілька запитань, які не мають жити разом. query — це вхід (пошук), count — це метадані відповіді (зазвичай для списку), id — це результат сервера, а status — це стан нашої локальної сутності. У підсумку в одному й тому самому об’єкті половина полів у половині сценаріїв буде null, а код почне виглядати як «вгадайте, яке поле сьогодні заповнене».
І це ще не найбільша проблема. Найбільша — контракт стає нечитабельним. Клієнт бачить поле count і думає: «Це завжди приходить? А в create-відповіді теж? А якщо ні — чому?». Потім він пише перевірки, потім пише документацію, потім ненавидить вас. І все це через те, що ми зекономили три маленькі класи.
7. Типові помилки під час роботи з request/response DTO
Коли ви вперше починаєте виділяти DTO, мозок майже автоматично намагається «спростити» і склеїти все назад. Це нормально: так працює звичка з консольних програм, де вхід і вихід часто живуть поруч і не потребують суворого контракту. Але у backend-світі такі спрощення ламають передбачуваність. Нижче — помилки, які трапляються найчастіше саме на старті.
Помилка №1: один DTO для create, update і response.
Зазвичай це починається невинно: «у create і update однакові поля, отже нехай буде один клас». Потім з’являється частковий сценарій (наприклад, змінити лише статус), потім з’являється поле, яке сервер додає сам (наприклад, id), і універсальний DTO перетворюється на мішок із null. У цей момент контракт стає розпливчастим, а код — схожим на детектив: «хто вбив поле author і чому воно раптом null?».
Помилка №2: додавати id до будь-якого request “за інерцією”.
id — корисне поле, але воно не має автоматично бути в усіх вхідних моделях. У create-сценарії id найчастіше генерується сервером, і якщо клієнт почне його надсилати, ви отримаєте незрозумілі сценарії: це створення? оновлення? спроба підміни? Навіть якщо ми в навчальному проєкті без безпеки, звичку краще формувати правильно.
Помилка №3: очікувати, що response зобов’язаний повторити request “поле в поле”.
Іноді здається логічним: «я надіслав 5 полів — поверни мені ті самі 5». Але response — це не дзеркало, а результат. Він може містити додаткові поля (наприклад, id), може не містити частину вхідних полів (якщо вони не мають сенсу для клієнта), а інколи взагалі бути іншою структурою. Здорова логіка тут така: response має бути корисним клієнту, а не красивим для нашого відчуття симетрії.
Помилка №4: змішувати критерії запиту й дані відповіді в одному об’єкті.
Пошук — найяскравіший приклад. query і limit — це вхід. items і externalId — це вихід. Коли вони опиняються в одному DTO, ви змушені тримати “напівпорожній” об’єкт, і далі це розповзається по всьому проєкту: десь заповнили query, десь заповнили items, а потім намагаються “універсально обробити”. Універсально зазвичай виходить лише страждання.
Помилка №5: перетворювати DTO на “розумний об’єкт” з логікою.
DTO на межі — це форма даних, а не місце для бізнес-правил. Щойно в DTO починають з’являтися методи «сам себе валідую», «сам себе нормалізую», «сам себе зберігаю», він перестає бути транспортною моделлю і перетворюється на незрозумілий гібрид. Для новачка це особливо небезпечно: потім важко пояснити, де закінчується контракт і починається логіка застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ