JavaRush /Курси /Java Server /Request і Response DTO

Request і Response DTO: різні задачі

Java Server
Рівень 11 , Лекція 0
Відкрита

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 починають з’являтися методи «сам себе валідую», «сам себе нормалізую», «сам себе зберігаю», він перестає бути транспортною моделлю і перетворюється на незрозумілий гібрид. Для новачка це особливо небезпечно: потім важко пояснити, де закінчується контракт і починається логіка застосунку.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ