JavaRush /Курси /Spring REST & MVC /Request/Response DTO та внутрішня модель

Request/Response DTO та внутрішня модель

Spring REST & MVC
Рівень 10 , Лекція 1
Відкрита

1. Ролі моделей: вхід, вихід, домен

Якщо ви тільки починаєте писати бекенд, то природна думка звучить так: «У мене є клас Task, давайте його і приймати, і віддавати — усе чесно, я ж нічого не приховую». Проблема в тому, що публічний API — це договір із зовнішнім світом, а внутрішній код — це ваша кухня. І кухню не завжди варто показувати клієнту: не тому, що там щось таємне, а тому, що вона постійно змінюється заради зручності роботи кухаря.

Ми вже побачили, чим закінчується пряма видача Task: доменна модель випадково починає диктувати зовнішній JSON. Тепер потрібно розкласти ролі по місцях і зрозуміти, яка модель відповідає за вхід, яка — за вихід, а яка — за внутрішнє життя застосунку.

Головна ідея сьогоднішньої лекції дуже проста: у даних є різні ролі, і одна Java-модель майже ніколи не справляється одразу з трьома ролями без побічних ефектів.

Уявіть (трохи театрально, але корисно), що в нас є три «мови»:

  • клієнт говорить мовою вхідних даних: «ось що я хочу створити/змінити»;
  • сервер відповідає мовою вихідних даних: «ось що в тебе тепер є»;
  • сервіси та репозиторії всередині застосунку живуть мовою внутрішнього стану: «ось як ми це зберігаємо і з чим працюємо».

Якщо змішати ці мови в одну, вийде як у поганому перекладі: ніби слова знайомі, а зміст скаче.

Щоб зробити це максимально приземлено, зручно тримати в голові ось таку таблицю (вона не про “анотації”, а про зміст):

Модель Куди спрямована Що описує Де живе в проєкті Приклад
Request DTO від клієнта до сервера що клієнт може надіслати у конкретній операції api.dto.request TaskCreateRequest
Response DTO від сервера до клієнта що сервер обіцяє повернути у конкретній відповіді api.dto.response TaskDetailsResponse
Внутрішня модель усередині застосунку як застосунок зберігає й обробляє стан domain.model Task

Зверніть увагу на формулювання: request DTO — «може надіслати», response DTO — «обіцяє повернути», внутрішня модель — «як живемо всередині». Якщо ви тримаєте в голові ці три смисли, половина архітектурних помилок зникає сама (друга половина, щоправда, знайде нові способи з’явитися — але це вже традиції).

2. Request DTO: що можна надіслати

Request DTO часто сприймають як «ще один клас заради ще одного класу». На практиці це найдешевший спосіб зробити API передбачуваним: ви буквально описуєте форму входу. Не «який у нас об’єкт усередині», а «які поля ми приймаємо від клієнта». І це дуже сильна думка: клієнт не повинен вгадувати, які поля «можна», а які — «краще не треба, але технічно прокотить».

Request DTO живе в api-шарі, зазвичай у пакеті на кшталт com.example.tasktracker.api.dto.request. Це його природне середовище: він існує рівно тому, що існує зовнішній контракт. Усередині сервісу request DTO не повинен бути «головною моделлю життя», інакше ваш domain-шар починає залежати від API-шару, а це вже архітектурний смуток.

Мінімальний request DTO для створення задачі може виглядати так:

public class TaskCreateRequest {

    // Поля, які клієнт має право надіслати під час створення задачі
    private String title;
    private String description;
    private String assigneeName;

    // Потрібен для десеріалізації JSON -> Java-обʼєкт (наприклад, Jackson)
    public TaskCreateRequest() { }

    // Гетери використовуються фреймворком/кодом контролера для читання вхідних даних
    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public String getAssigneeName() { return assigneeName; }
}

Зверніть увагу, тут немає id, status, createdAt та інших речей, які звучать як «це сервер знає краще». І навіть якщо ви поки не додавали такі поля у внутрішню модель, request DTO — це місце, де ви заздалегідь тренуєте дисципліну: вхід має бути вузьким і контрольованим.

Щоб мозку було легше, корисно іноді подумки перекладати request DTO на людську мову. TaskCreateRequest — це не «модель Task», а «лист від клієнта: створи задачу з такими-то даними». Лист зазвичай не містить рядок “а ще признач мені id=123 і постав статус DONE, я поспішаю” — точніше, клієнт може захотіти, але ми не зобов’язані погоджуватися.

Невеликий приклад JSON, який добре лягає в такий DTO (просто для відчуття форми, без тонкощів серіалізації):

{
  "title": "Полагодити збірку",
  "description": "Gradle свариться на залежності",
  "assigneeName": "Alex"
}

Request DTO добре тим, що він одразу відповідає на запитання: «а що я взагалі маю надіслати?». Якщо DTO не відповідає на це запитання, значить він або занадто загальний, або виконує не свою роль.

3. Response DTO: що сервер обіцяє повернути

Response DTO — це модель, яку сервер відправляє клієнту. І тут є тонка пастка новачка: хочеться зробити “симетрію”. Мовляв, якщо клієнт надіслав title, description, assigneeName, то сервер поверне те саме, плюс ще кілька полів — і вийде “універсальна модель”.

Іноді так справді виходить випадково. Але щойно API стає трохи багатшим, симетрія починає заважати: вхід і вихід логічно різні. Вхід — це дозволений набір змін. Вихід — це обіцяне подання ресурсу.

Тому response DTO зазвичай живе в окремому пакеті com.example.tasktracker.api.dto.response і має свою форму. Наприклад, детальна відповідь по задачі:

public class TaskDetailsResponse {

    // Response DTO часто роблять незмінним: це «знімок» того, що ми віддаємо клієнту
    private final String id;
    private final String title;
    private final String description;
    private final String status;

    public TaskDetailsResponse(String id, String title, String description, String status) {
        // Тут ми фіксуємо те подання, яке обіцяємо повернути назовні
        this.id = id;
        this.title = title;
        this.description = description;
        this.status = status;
    }

    // Зазвичай потрібні гетери на всі поля для JSON-серіалізації (нижче показано ідею)
    public String getId() { return id; }
    public String getTitle() { return title; }
}

Так, тут уже є id і status, хоча клієнт їх не надсилав. Це нормально. Ба більше, це очікувано: клієнт запитує «що вийшло?», а сервер відповідає «вийшло ось що». Це схоже на чек у магазині: ви приносите товари (вхідні дані), а каса видає чек із номером операції, часом, сумою — ви цього не надсилали, але це важливо як факт результату.

Дуже важливий практичний момент: response DTO корисно робити максимально простим і “тупим”. Чим менше в ньому поведінки та «розумних» методів, тим краще він виконує роль контракту. Він не повинен ходити в репозиторій, запускати бізнес-логіку й «сам себе оновлювати». Його робота — бути зрозумілою формою даних, яку можна серіалізувати й віддати назовні.

4. Internal model: внутрішня модель домену

Внутрішня модель — це те, чим живе domain-шар. Вона потрібна не тому, що нам подобаються “шари заради шарів”, а тому, що сервісам і репозиторіям потрібна робоча модель: зручна для зберігання, оновлення, сортування, бізнес-правил і просто нормального життя.

У проєкті Task Tracker API внутрішня модель — це не публічний контракт. Це ваші «внутрішні документи». Якщо завтра ви вирішите зберігати якісь додаткові поля або змінити структуру зберігання тегів, клієнт не повинен раптово отримати це в JSON-відповіді просто тому, що ви додали поле в Java-клас.

Приклад спрощеної внутрішньої моделі Task (у пакеті com.example.tasktracker.domain.model) може виглядати так:

import java.time.Instant;

public class Task {

    // Те, чим система володіє всередині (зокрема службові поля)
    private String id;
    private String title;
    private String description;
    private String status;

    // Внутрішній стан може бути багатшим за DTO: наприклад, факт часу створення
    private Instant createdAt;

    public String getId() { return id; }
    public String getTitle() { return title; }
    public String getStatus() { return status; }
}

Чому тут може бути createdAt, а в request DTO — ні? Тому що це факт стану ресурсу, який встановлює сервер. Клієнт може хотіти керувати часом (особливо якщо клієнт — машина часу), але наша система зазвичай живе в менш фантастичній реальності.

Чому внутрішня модель може змінюватися частіше, ніж DTO? Тому що вона відображає вашу реалізацію. Ви змінюєте реалізацію, коли покращуєте код, додаєте поля для зручності, оптимізуєте зберігання, запроваджуєте нові бізнес-правила. Клієнти ж хочуть стабільності. DTO якраз і виступають «амортизатором» між “ми всередині змінили” та “клієнту все ще зрозуміло і не боляче”.

Ще один корисний погляд: внутрішня модель існує навіть якби не було HTTP. Уявіть, що завтра ви захотіли викликати ваш TaskService не через REST, а з іншого Java-коду (наприклад, batch-процесу). Внутрішній моделі буде байдуже. А ось request/response DTO — це суто API-історія.

5. Межа: контролер і сервіс

Контролер у Spring MVC — це не місце для бізнес-логіки, але це нормальне місце для перекладу форм даних. Він стоїть на межі: зовні JSON і DTO, всередині сервіси та внутрішня модель. Тому контролер часто виглядає як акуратний диспетчер: прийняв request DTO, викликав сервіс, перетворив внутрішній результат на response DTO.

І тут з’являється правило, яке спочатку здається занудним, а потім рятує проєкт: сервісний шар не повинен залежати від DTO API-шару. Не тому, що “так написано в розумних книжках”, а тому, що інакше ви прив’язуєте бізнес-логіку до формату HTTP-входу/виходу.

Поганий (але дуже спокусливий) варіант виглядає приблизно так:

public interface TaskService {

    // Погано: доменний сервіс починає «думати» DTO-шками зовнішнього API
    TaskDetailsResponse create(TaskCreateRequest request);
}

На перший погляд зручно: сервіс “сам усе зробить”. На другий погляд ви вже протягнули api.dto.request і api.dto.response у domain.service. Тобто домен тепер знає про зовнішній контракт. А отже, будь-яка зміна зовнішнього контракту починає впливати на домен — і навпаки. Це як почати проєктувати кухню ресторану, виходячи з того, якого кольору меню. Можна, але потім складно пояснити собі, навіщо.

Для самого принципу достатньо навіть такого варіанта: сервіс отримує потрібні дані, але не починає жити DTO-шками зовнішнього API:

public interface TaskService {

    // Добре: сигнатура описує доменну дію і потрібні для неї дані
    // (а не форму HTTP-запиту)
    Task create(String title, String description, String assigneeName);
}

Форма сигнатури тут не священна. Ті самі дані можна передавати набором параметрів, окремою командою або доменною чернеткою Task. Важлива сама межа: domain.service не залежить від api.dto.*.

Сервіс повертає внутрішню модель Task. А контролер уже вирішує, що саме і в якому вигляді віддавати назовні.

Приклад методу контролера, який показує три ролі в одному місці (і це нормально, бо контролер — межа):

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping("/api/v1/tasks") // HTTP-маршрут: це відповідальність контролера
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
    // 1) На вході: request DTO (те, що надіслав клієнт)
    Task task = taskService.create(
        request.getTitle(),
        request.getDescription(),
        request.getAssigneeName()
    );

    // 2) Усередині: internal model (те, з чим працює домен)
    // 3) На виході: response DTO (те, що обіцяємо повернути клієнту)
    return new TaskDetailsResponse(
        task.getId(),
        task.getTitle(),
        task.getDescription(),
        task.getStatus()
    );
}

Навіть якщо замість набору параметрів ви потім зберете доменну чернетку Task і передасте її в сервіс, зміст не змінюється: request DTO залишається на API-межі, а сервіс не починає залежати від HTTP-моделі.

Тут важливо побачити структуру думки: request DTO — на вході, внутрішня модель — усередині після сервісу, response DTO — на виході. І в кожної моделі своя роль. Якщо ви навчитеся бачити це «трьома шарами даних», то далі буде набагато легше підтримувати дисципліну API.

6. Потік даних у create-сценарії

Щоб усе це не залишилося «теорією про правильну архітектуру», корисно подумки пройти один простий запит від початку до кінця. Create-сценарій хороший тим, що він показує напрямок даних: клієнт надсилає вхід, сервер формує стан, сервер віддає вихід. І в цьому місці якраз видно, чому однією моделлю все це зробити важко, навіть якщо «дуже хочеться швидше».

Давайте зафіксуємо потік без зайвих деталей і без переходу до статусів HTTP-відповідей. З погляду даних він виглядає так:

flowchart TD
    %% Зовні: JSON запиту перетворюється на DTO запиту
    A["JSON (тіло запиту)"] --> B["TaskCreateRequest (request DTO)"]
    %% Усередині: доменний сервіс створює й повертає внутрішню модель
    B --> C["TaskService (домен)"]
    C --> D["Task (внутрішня модель)"]
    %% На виході: внутрішню модель маплять у DTO відповіді та серіалізують у JSON
    D --> E["TaskDetailsResponse (response DTO)"]
    E --> F["JSON (тіло відповіді)"]

Середина цього ланцюжка може бути оформлена по-різному, але два правила залишаються: request DTO не протікає в домен напряму, а response DTO з’являється вже після доменної операції.

Якщо описати це людськими словами, вийде майже нудно (а це хороший знак): клієнт надіслав дані для створення, сервер створив задачу, сервер повернув представлення створеної задачі. І тут відбувається магія… точніше, переклад: дані ніби проходять через різні форми, але зміст стає тільки яснішим.

Щоб було зовсім приземлено, можна уявити один із коротких варіантів, як усередині сервісу задача «народжується» так:

import java.time.Instant;
import java.util.UUID;

public Task create(String title, String description, String assigneeName) {
    // Створюємо внутрішню модель: це обʼєкт, з яким «живе» домен
    Task task = new Task();

    // Сервер задає те, що клієнт не повинен диктувати напряму
    task.setId(UUID.randomUUID().toString()); // унікальний ідентифікатор
    task.setTitle(title);                     // користувацькі дані
    task.setDescription(description);         // користувацькі дані
    task.setStatus("TODO");                   // стартовий статус задає система
    task.setCreatedAt(Instant.now());         // факт часу теж задає система

    // Передбачається, що в Task є сетери (у прикладі вище їх не показано)
    return task;
}

Так, тут з’являються значення, яких не було у вході: id, status, createdAt. Це і є та сама різниця ролей: request DTO не повинен містити все, що з’явиться врешті-решт. Він містить те, що клієнт вводить, а внутрішня модель містить те, чим система володіє.

7. Типові помилки під час розділення моделей

Помилки в цій темі зазвичай не виглядають як «червона помилка компіляції». Вони виглядають як «усе працювало, а через два тижні стало боляче змінювати». Тому корисно заздалегідь знати, де найчастіше наступають на граблі — і, за можливості, наступати на них хоча б у м’яких капцях.

Помилка №1: зробити request DTO і response DTO однаковими «про всяк випадок».
Це виглядає як економія, але насправді ви змішуєте дві різні ролі. Вхідна модель починає розростатися полями, які клієнту надсилати не потрібно, а вихідна модель — полями, які клієнту бачити не обов’язково. Підсумок — розмитий контракт і вічне запитання «а чи можна це поле надсилати/змінювати?».

Помилка №2: додати у вхідну модель службові поля «для зручності».
Найчастіший симптом — поява id, status, createdAt у request DTO. Навіть якщо ви поки не бачите проблеми, це робить контракт “занадто широким”: клієнт починає керувати тим, що має задаватися сервером. І потім дуже важко «відкотити назад», бо клієнт уже звик, що «так можна».

Помилка №3: сервіс або репозиторій починають працювати на response DTO.
Коли domain.service повертає TaskDetailsResponse, це означає, що бізнес-шар тепер думає категоріями HTTP-відповіді. Сьогодні це здається зручним, а завтра ви захочете повернути інший формат відповіді (або зробити другий endpoint з іншим поданням) — і раптом бізнес-логіка починає ламатися від зміни форми виводу.

Помилка №4: переконання, що “якщо поле є у відповіді — воно зобов’язане бути і у вході”.
Це психологічно схоже на «симетрія красива». Але API — не дзеркало. Поля у відповіді часто ширші, бо сервер повідомляє факти: id, status, час створення. Якщо ви змушуєте вхід «повторювати» вихід, ви даєте клієнту зайві важелі впливу на стан.

Помилка №5: HTTP-логіка протікає в сервісний шар.
Якщо сервіс починає повертати ResponseEntity, приймати HttpServletRequest або вирішувати, який статус-код ставити, ви втрачаєте межу: домен стає залежним від вебу. Контролер у цей момент перетворюється на «переадресатор», а сервіс — на «контролер під маскою». У навчальному проєкті це особливо помітно: код ніби невеликий, а відчуття — наче ви заблукали в трьох соснах.

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