1. Робочий контролер — не завжди хороший API
Коли ви лише починаєте писати API, найприємніший момент — коли він нарешті відповідає. Ви ставите @GetMapping, повертаєте об’єкт, запускаєте ./gradlew bootRun, надсилаєте запит — і у відповідь прилітає JSON. У цей момент мозок каже: «Усе, можна святкувати, ми backend-розробники». Але є неприємна правда: працюючий endpoint — це ще не контракт. Поки що це лише доказ того, що Spring уміє серіалізувати ваш об’єкт.
У нас уже є робочий controller -> service skeleton. Тепер питання не в тому, чи вміє endpoint відповідати, а в тому, який саме JSON він починає обіцяти клієнту.
Уявіть, що ви написали ось так — «нашвидкуруч»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
// Важливо: так ви безпосередньо віддаєте внутрішню модель назовні.
// Усе, що є в Task (поля/гетери), може стати частиною публічного JSON-контракту.
return taskService.findById(taskId);
}
Це працює. І саме тому це небезпечно.
Бо з цього моменту ваш зовнішній контракт, тобто те, що побачить клієнт, — це не те, що ви свідомо спроєктували, а те, що випадково вийшло з поточного стану класу Task. Сьогодні в нього одні поля, завтра інші, а клієнту зазвичай байдуже на ваше внутрішнє життя: він очікуватиме стабільності.
2. Внутрішня модель
Внутрішня модель — це те, як застосунок представляє дані для себе. Її завдання — бути зручною для сервісів, репозиторіїв, логіки оновлення, пошуку і взагалі для того, щоб код усередині проєкту був читабельним і підтримуваним. І ось тут є ключовий нюанс: внутрішня модель за визначенням змінюватиметься частіше, ніж вам хочеться. Не тому, що ви «поганий розробник», а тому, що так влаштоване життя: код зростає.
Наприклад, сьогодні ви вирішили додати службове поле, яке допомагає вам у розробці або налагодженні:
import java.util.Set;
public class Task {
private String id;
private String title;
private Set<String> tags;
// Службове поле: корисне всередині застосунку, але не повинно ставати частиною публічного API.
private String internalNote;
}
Тут internalNote може бути корисним усередині застосунку: ви можете зберігати там щось на кшталт «чому задача зʼявилася в seed data» або «який тест на неї завʼязаний». Усередині — нормально. Але якщо ви віддаєте Task назовні, це поле раптово стає частиною публічного API.
І це не єдиний тип «внутрішніх» речей. Внутрішня модель часто включає технічні та інфраструктурні деталі, які клієнту не потрібні: проміжні значення, нормалізовані поля, кешовані представлення, прапорці для внутрішньої логіки. Це як кухня ресторану: там багато корисних предметів — ножі, ганчірки, контейнери, — але якщо винести кухню в зал, відвідувачі чомусь перестають думати, що це «висока кухня».
3. Серіалізація Task і HttpMessageConverter
Дуже легко помилково думати так: «Ну я ж повертаю об’єкт. А JSON — це ніби окрема штука, яка десь там “сама по собі”.» У Spring MVC це не окрема штука, а прямий результат того, що ви повернули з контролера. Контролер повернув Task → Spring MVC знайшов відповідний HttpMessageConverter → Jackson серіалізував об’єкт → клієнт отримав JSON.
Зручно тримати це в голові як просту схему:
flowchart TD
A[Клієнт надсилає HTTP-запит] --> B[Метод контролера Spring MVC]
B --> C[Значення, яке повертається: Task]
C --> D[HttpMessageConverter]
D --> E[Jackson серіалізує в JSON]
E --> F[Тіло HTTP-відповіді для клієнта]
Важливий практичний висновок: якщо ви повернули внутрішній об’єкт, то буквально сказали Spring: «Будь ласка, перетвори ось це на публічний контракт». Jackson дивитиметься на властивості об’єкта — зазвичай через гетери, іноді через поля, залежно від правил видимості, — і старанно зробить із цього JSON.
Окрема кумедна, і водночас неприємна, ситуація — коли ви додаєте «нешкідливий» гетер, який потрібен вам усередині, а він раптом перетворюється на поле відповіді:
public String getSearchIndex() {
// Внутрішній обчислюваний гетер: зручний для пошуку в межах сервісу/репозиторію,
// але якщо віддати Task напряму, він може "спливти" як поле searchIndex у JSON.
return (title + " " + description).toLowerCase();
}
Усередині проєкту ви хотіли лише «зручний шматок для пошуку». А назовні тепер потенційно вилітає searchIndex, і клієнт починає думати, що це важливе поле контракту. Вітаю: ви щойно публічно пообіцяли те, чого не збиралися обіцяти.
4. Витік зайвих полів
Найзрозуміліша проблема прямої видачі внутрішньої моделі — це витік зайвих даних. Причому це не обов’язково «секрети рівня банківських ключів» — їх взагалі не можна зберігати в доменній моделі в такому вигляді. Це може бути просто зайве: технічне, проміжне, тимчасове. Але щойно клієнт це побачив, він може почати на це спиратися.
Подивімося на мінісценарій. Внутрішня модель стала трохи багатшою:
public class Task {
private String id;
private String title;
private String status;
// Технічне поле: для клієнта "зайве", але за прямої серіалізації воно витече у відповідь.
private String internalNote; // "не віддавати назовні", але хто ж його зупинить?
}
А контролер і далі «щасливо віддає Task»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
// Поки повертаємо Task напряму — будь-яке нове поле або гетер у Task потенційно стає полем API.
return taskService.findById(taskId);
}
Що побачить клієнт? Приблизно такий JSON, спрощено:
{
"id": "b7b5b6b7-3d33-4c2f-a9b5-3d3d3d3d3d3d",
"title": "Сплатити оренду",
"status": "TODO",
"internalNote": "seed:task-42"
}
І ось тут починається «комедія помилок». Клієнт може подумати: «О, internalNote, отже це корисна примітка, будемо показувати її в UI». Або: «internalNote — зручний ідентифікатор». Або: «За цим полем можна фільтрувати». Клієнт не зобов’язаний вгадувати, що це тимчасова латка або службова штука.
У підсумку ви стаєте заручником: поле, яке зʼявилося у відповіді випадково, перетворюється на частину контракту. Видалити його потім — це вже breaking change.
5. Випадкові breaking changes
Друга велика проблема — це крихкість контракту. Внутрішній код ви маєте право покращувати: перейменувати поле, змінити тип, розбити одне поле на два, винести частину логіки в інший об’єкт. Але якщо зовнішній JSON безпосередньо слідує внутрішній моделі, то будь-який рефакторинг стає «публічною подією».
Уявімо дуже побутову ситуацію. Усередині проєкту ви перейменували поле assigneeName на executorName, бо вам так зрозуміліше, або тому, що в домені компанії прийнято слово «виконавець», а не «призначений». Це нормальний рефакторинг.
До рефакторингу клієнт бачив:
{
"id": "…",
"title": "Подзвонити мамі",
"assigneeName": "Alex"
}
Після рефакторингу клієнт раптово бачить:
{
"id": "…",
"title": "Подзвонити мамі",
"executorName": "Alex"
}
Для сервера це «я просто навів лад». Для клієнта — «ваш API зламався». І найнеприємніше: вам навіть нічим виправдатися, окрім чесного «ну ми там усередині перейменували, вибачте».
І так, іноді додавання нового поля у відповідь вважається безпечною зміною: клієнти зазвичай ігнорують невідомі поля. Але ви не контролюєте всіх клієнтів у світі, особливо якщо API стане публічним. Деякі клієнти бувають «строгими» і падають від неочікуваного поля — рідко, але буває. Тому стратегія «будемо змінювати внутрішню модель як хочемо, а клієнти потерплять» — це не стратегія, а спосіб накопичити майбутній біль.
6. Контракт і представлення: список vs деталі
Третя проблема прямої видачі внутрішньої моделі — неможливість нормально керувати представленнями ресурсу. У REST-світі один і той самий ресурс часто має різні форми в різних операціях: список зазвичай вимагає стислості, детальний endpoint — повноти, а деякі внутрішні поля взагалі не повинні з’являтися назовні ніколи. Поки нам важливий сам принцип: одна доменна модель не зобов’язана обслуговувати всі зовнішні представлення одразу.
Навіть якщо зараз ваш Task виглядає «чисто», завтра ви захочете додати, наприклад, довгий опис, службові поля або обчислювані властивості. І якщо ви віддаєте Task як є, то список задач почне «тягнути» все підряд. У підсумку GET /api/v1/tasks стає важким і шумним просто тому, що десь усередині вам знадобилося додати поле.
Виглядає це так: ви хочете, щоб список був компактним і швидким для клієнта. У списку зазвичай достатньо id, title, status. Але якщо ви повертаєте внутрішній Task, клієнт неминуче отримає ще й description, і internalNote, і все, що ви додасте далі «для себе».
Так, можна намагатися викручуватися: обнуляти поля перед видачею, «якщо це list endpoint», писати умовну логіку серіалізації, городити ручний Map<String, Object>. Але це зазвичай закінчується тим, що у вас з’являється не один контракт, а багато випадкових контрактів і жодного стабільного.
7. Response DTO як «пресреліз» вашого ресурсу
Response DTO — це ваша офіційна публічна модель відповіді. Якщо внутрішня модель — це робочий зошит, де можна закреслювати, переписувати й змінювати рішення на ходу, то Response DTO — це «версія для друку», яку ви віддаєте зовнішньому світу. Важлива думка: клієнту не потрібно знати, як ви зберігаєте дані всередині. Клієнту потрібно знати, що саме ви обіцяєте віддати.
Поки неважливо, як саме називатимуться різні варіанти відповіді. На цьому кроці достатньо побачити більш базову річ: назовні має йти окремий response DTO, а не Task.
Наприклад, мінімальна відповідь може виглядати так:
public class TaskResponse {
// Поля DTO — це і є контракт: додаєте або видаляєте їх лише свідомо.
private final String id;
private final String title;
private final String status;
public TaskResponse(String id, String title, String status) {
// DTO зазвичай простий: ми просто переносимо ті дані, які справді хочемо показати клієнту.
this.id = id; this.title = title; this.status = status;
}
// Гетери DTO — це "дозволені" поля відповіді.
public String getId() { return id; }
public String getTitle() { return title; }
public String getStatus() { return status; }
}
Тепер у вашого API з’являється явна межа: що б не відбувалося всередині Task, назовні все одно виходить рівно те, що ви поклали в TaskResponse. Хочете — додаєте поле. Не хочете — не додаєте. Жодних сюрпризів на кшталт «ой, я додав internalNote, і воно стало публічним».
8. Мінірефакторинг у Task Tracker API
Щоб це не звучало як теорія з книжки, зробімо мінімальний крок просто в межах нашого Task Tracker API. Нам поки не потрібен повний набір DTO за операціями; спочатку важливіше просто перестати повертати внутрішній об’єкт безпосередньо з контролера.
Уявімо, що в нас є внутрішній Task:
public class Task {
private String id;
private String title;
private String status;
// Внутрішнє поле: може бути дуже важливим для сервера, але клієнту про нього знати не потрібно.
private String internalNote;
public String getId() { return id; }
public String getTitle() { return title; }
public String getStatus() { return status; }
}
Тепер контролер замість Task починає повертати TaskResponse:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public TaskResponse getTask(@PathVariable String taskId) {
// Усередині можна отримувати доменну модель з будь-якими технічними полями.
Task task = taskService.findById(taskId);
// А назовні віддаємо рівно те, що вважаємо частиною API-контракту.
return new TaskResponse(task.getId(), task.getTitle(), task.getStatus());
}
Ключовий момент: ви буквально «перезбираєте» відповідь вручну. Це виглядає як зайва робота, але саме в цьому й сенс: ви робите контракт явним. Внутрішнє поле internalNote тепер може хоч жити, хоч змінюватися, хоч розмножуватися — воно не потрапить у JSON, тому що ви не включили його в DTO.
Результат для клієнта стає передбачуваним і коротким:
{
"id": "…",
"title": "Сплатити оренду",
"status": "TODO"
}
Від цього моменту, коли ви змінюєте внутрішню модель, ви більше не граєте в «вгадай, що побачить клієнт». Клієнт побачить лише те, що ви поклали в response DTO.
### Внутрішня модель vs response DTO
Щоб не тримати все це в голові як абстрактні слова, корисно один раз «прибити до стіни» порівняння. Внутрішня модель і response DTO схожі тим, що обидва є Java-класами. Але ролі в них різні, і змішувати їх — щонайменше шкідливо для психіки команди, а іноді й для API.
| Аспект | Внутрішня модель | Response DTO |
|---|---|---|
| Головна мета | Зручність логіки всередині застосунку | Явний і стабільний зовнішній контракт |
| Хто “власник” | Сервер (ви і ваша команда) | Клієнти API (і ви як автор контракту) |
| Частота змін | Часто змінюється через рефакторинг і розвиток | Змінюється обережно, свідомо |
| Що може містити | Службові поля, кеші, допоміжні властивості | Лише те, що ви готові обіцяти назовні |
| Оптимізація | Можна оптимізувати під зберігання і внутрішню роботу | Оптимізуєте під читабельність і зручність клієнта |
| Ціна помилки | Зламаєте внутрішню логіку (зазвичай швидко виправляється) | Ламаєте клієнтів і довіру до API (виправляється довго) |
Коротко: внутрішня модель — це «як нам зручно працювати», DTO — це «як ми домовляємося із зовнішнім світом».
9. Типові помилки під час роботи з DTO
Помилка №1: «Поки проєкт маленький, можна віддавати внутрішню модель».
Це дуже людська логіка, але вона підводить саме тому, що маленький проєкт швидко перестає бути маленьким. Ви додасте одне службове поле, один обчислюваний гетер, одне посилання на внутрішній об’єкт — і зовнішній контракт почне розповзатися. Потім ви будете розбирати це і пояснювати клієнтам, чому раптово зникло поле, яке ви «ніколи не обіцяли».
Помилка №2: спроба зробити внутрішню модель «ідеальною для API» замість DTO.
Іноді здається, що можна перемогти проблему так: «Давайте просто будемо дуже акуратними і не додавати нічого зайвого в Task». Це призводить до того, що внутрішня модель починає обслуговувати зовнішній контракт, а не внутрішню логіку. У результаті страждає і контракт, бо витоки все одно з’являються, і внутрішній код, бо ви боїтеся змінювати модель, навіть коли це потрібно.
Помилка №3: не пам’ятати, що публічний гетер — це майже публічне поле API.
Ви додаєте метод getSomethingUseful() для внутрішньої зручності, не думаєте про контролер, а потім дивуєтеся, що клієнт раптово отримав поле somethingUseful у JSON. Це особливо підступно в проєктах, де люблять додавати «красиві» обчислювані гетери. Якщо об’єкт потрапляє назовні, то гетери стають частиною API, навіть якщо ви цього не планували.
Помилка №4: повертати «як є», а потім намагатися лікувати це хаками.
Сюди належать підходи на кшталт «давайте перед віддачею в list endpoint занулемо description», «давайте на льоту видалимо поля з об’єкта», «давайте повернемо Map<String, Object> і вручну зберемо JSON». Зазвичай це перетворює код на набір костилів, бо контракт усе одно залишається неявним, а логіка формування відповіді розмазується по контролерах.
Помилка №5: змішувати DTO і доменну модель в одному пакеті та з однаковими іменами.
Якщо ви створюєте Task і в domain.model, і в api.dto.response, а потім починаєте імпортувати «не той Task» — так, це трапляється регулярно, — проєкт стає схожим на детектив: «Хто з них справжній Task?». Краще відразу привчити себе до дисципліни: внутрішні моделі живуть у domain.model, публічні відповіді — у api.dto.response, і вони називаються так, щоб з імені була видна роль.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ