1. Неминучий мапінг після DTO
Щойно у create, list, detail і update зʼявляються свої DTO, виникає цілком реальна проблема: дані більше не можуть самі «протекти» з JSON у домен і назад. Їх потрібно перекладати з однієї форми в іншу. Це не «зайвий шар», а плата за контроль над контрактом. Як в аеропорту: можна летіти з ручною поклажею (одна модель на все), а можна з валізою та документами (DTO + мапінг) — трохи складніше, зате передбачувано.
Саме слово мапінг часто звучить як щось зі світу картографії та магії: «ми замапимо, воно замапиться». На практиці це звичайна інженерна робота: взяти поля з TaskCreateRequest, покласти їх у доменну Task (або хоча б у доменний об’єкт, з яким працює сервіс), а потім — навпаки — взяти Task і зібрати TaskSummaryResponse або TaskDetailsResponse.
Важливо зафіксувати: мапінг — це не бізнес-логіка. Це переклад форми даних. Якщо сервіс вирішує що робити, то mapper вирішує як упакувати це в потрібну форму.
Щоб картинка склалася, корисно тримати в голові простий потік:
flowchart TD A[JSON-запит] --> B[TaskCreateRequest] B --> C[TaskMapper] C --> D["Внутрішній Task"] D --> E[TaskService] E --> F["Внутрішній Task"] F --> G[TaskMapper] G --> H[TaskDetailsResponse] H --> I[JSON-відповідь]
Це й є звичайний робочий маршрут межі API: request DTO приходить ззовні, перетворюється на внутрішній об’єкт, проходить через сервіс і потім збирається в response DTO.
Тут немає «секретного Spring-механізму». Jackson перетворює JSON у request DTO і назад, а все, що між DTO та внутрішньою моделлю, — наша відповідальність.
2. Два напрями мапінгу: вхід і вихід
Коли починаєте писати мапінг, є спокуса «зробити один універсальний метод» і жити щасливо. Але реальність зазвичай швидко дає по руках: вхідний мапінг і вихідний мапінг — це різні сутності, і змішувати їх незручно навіть у голові, не те що в коді.
Вхідний мапінг відповідає на запитання: «як із того, що надіслав клієнт, отримати внутрішній об’єкт, з яким зручно працювати всередині застосунку?». Він майже завжди вужчий, бо клієнт не має надсилати все підряд.
Вихідний мапінг відповідає на запитання: «як із внутрішнього об’єкта зібрати те представлення, яке обіцяє конкретний endpoint?». І тут зʼявляється важлива деталь: для list і для detail це різні представлення, отже й мапінг буде різний.
Зручно зафіксувати це в невеликій таблиці, щоб мозок не намагався склеїти дві різні задачі в одну:
| Напрям | Звідки → куди | Приклад у Task Tracker API | Що головне |
|---|---|---|---|
| Вхідний мапінг | request DTO → internal model | TaskCreateRequest → Task (чернетка) | переносимо лише те, що клієнт реально надсилає |
| Вихідний мапінг (summary) | internal model → response DTO | Task → TaskSummaryResponse | коротко, зручно для списку |
| Вихідний мапінг (details) | internal model → response DTO | Task → TaskDetailsResponse | детальніше, зручно для одного ресурсу |
Ця тричастинна схема сильно спрощує життя: ви не шукаєте «ідеальний DTO», а робите кілька простих перетворень, кожне з яких відповідає за одну зрозумілу форму.
3. Де має жити mapper
На маленьких прикладах дуже хочеться зробити так: «у контролері отримав Task, тут же руками зібрав TaskDetailsResponse і повернув». Це працює, але нагадує життя на кухні під час ремонту. А ремонт, як ми знаємо, іноді затягується на роки.
Коли мапінг розмазаний по контролерах, ви отримуєте два неприємні ефекти. По-перше, контролер перестає бути тонким: він починає займатися рутинним перекладанням даних, а потім туди ж додається «трохи логіки», «трохи умов», і ви вже непомітно отримуєте жирний пиріжок замість тонкого шару. По-друге, однаковий мапінг починає дублюватися: один і той самий Task ви перетворюєте в TaskSummaryResponse у трьох місцях, і всюди це трохи по-різному. А потім дивуєтеся, чому API «пливе».
У нашому проєкті це вирішується акуратно й передбачувано: ми кладемо mapper у пакет com.example.tasktracker.api.mapper. Це підкреслює думку: мапінг належить до межі API, тобто до місця, де внутрішній світ зустрічається із зовнішнім контрактом.
Мінімальний каркас mapperʼа виглядає так:
package com.example.tasktracker.api.mapper;
import org.springframework.stereotype.Component;
@Component // Робимо mapper Spring beanʼом, щоб впроваджувати його без ручного new
public class TaskMapper {
// Mapper — це «перекладач» між DTO та внутрішньою моделлю на межі API
// Тут будуть методи мапінгу
}
Анотація @Component потрібна не для краси: так mapper стане Spring beanʼом, і ми зможемо акуратно впроваджувати його в контролери або інші компоненти API-шару, не створюючи руками new TaskMapper() у кожному класі.
4. Input-мапінг: TaskCreateRequest → чернетка Task
Зараз ми зробимо важливу річ: навчимося збирати внутрішню модель з request DTO так, щоб мапінг був простим і без сюрпризів. На цьому етапі нам не потрібно вигадувати хитрі правила. Наша мета — чесно скопіювати поля, які прийшли від клієнта, і отримати об’єкт, з яким працюватиме сервіс.
Щоб не потонути в полях, нижче залишимо лише той зріз Task, на якому видно сам переклад.
Почнемо з request DTO. Він живе у api.dto.request і містить лише поля, які клієнт справді надсилає під час створення задачі:
package com.example.tasktracker.api.dto.request;
public class TaskCreateRequest {
// Поля, які клієнт надсилає під час створення задачі
private String title;
private String description;
private String assigneeName;
public String getTitle() { return title; }
public String getDescription() { return description; }
public String getAssigneeName() { return assigneeName; }
}
Тепер внутрішня модель Task (доменні поля ми тут показуємо мінімально, щоб не потонути в деталях):
package com.example.tasktracker.domain.model;
public class Task {
// Внутрішні поля доменної моделі: назовні їх напряму не віддаємо
private String id;
private String title;
private String description;
private String status;
private String assigneeName;
// У прикладі показуємо лише те, що потрібно для input-мапінгу
public void setTitle(String title) { this.title = title; }
public void setDescription(String description) { this.description = description; }
public void setAssigneeName(String assigneeName) { this.assigneeName = assigneeName; }
}
І ось мапінг у TaskMapper. Ми свідомо робимо метод, який створює чернетку Task: переносить client-provided поля, але не намагається вирішувати, яким буде id і яким буде статус. Mapper — перекладач, а не автор сценарію.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
@Component
public class TaskMapper {
public Task toDraftTask(TaskCreateRequest request) {
// Створюємо «чернетку» доменного об'єкта з request DTO
Task task = new Task();
// Переносимо лише те, що реально надійшло від клієнта
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
task.setAssigneeName(request.getAssigneeName());
// Важливо: id/статус тут не задаємо — це відповідальність сервісу/домену
return task;
}
}
Зверніть увагу на приємну річ: цей метод читається як коротка історія. Навіть якщо ви не любите Java, мозок усе одно розуміє, що тут відбувається. Жодної магії, жодного reflection, жодного «воно саме скопіювало однакові поля».
5. Вихідний мапінг: Task → Summary і Details
Тепер займімося зворотним напрямом: із внутрішньої моделі потрібно зібрати відповідь. І тут ми одразу робимо два різні методи, бо «summary» і «details» — це два різні контракти. Так, навіть якщо сьогодні вони відрізняються лише одним полем. Бо завтра вони майже напевно почнуть відрізнятися сильніше.
Самі DTO теж залишимо короткими: summary відповідає за список, details — за повніше представлення однієї задачі.
Спочатку response DTO для списку. Зазвичай у summary залишають найголовніше й найкоротше: id, title, статус.
package com.example.tasktracker.api.dto.response;
public class TaskSummaryResponse {
// DTO відповіді: фіксуємо публічний контракт (краще робити такі DTO незмінними)
private final String id;
private final String title;
private final String status;
public TaskSummaryResponse(String id, String title, String status) {
this.id = id;
this.title = title;
this.status = status;
}
}
А для details — більш повне read-представлення. Тут додамо опис.
package com.example.tasktracker.api.dto.response;
public class TaskDetailsResponse {
// Детальний контракт зазвичай містить більше полів, ніж summary
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;
}
}
Тепер доповнимо TaskMapper двома методами:
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class TaskMapper {
public TaskSummaryResponse toSummaryResponse(Task task) {
// Summary-відповідь: коротке представлення (наприклад, для списку)
return new TaskSummaryResponse(
task.getId(),
task.getTitle(),
task.getStatus()
);
}
public TaskDetailsResponse toDetailsResponse(Task task) {
// Details-відповідь: повніше представлення (наприклад, для картки задачі)
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus()
);
}
}
Якщо ви зараз подумали «а чому тут так багато однакового?», то вітаю: ви думаєте як розробник. Але це той випадок, коли однаковість — це добре. Ми хочемо, щоб мапінг був нудним. Нудний мапінг зазвичай означає передбачуваний контракт, а передбачуваний контракт — це щастя для клієнта і менше болю для нас.
6. Контролер і колекції
Мапінг колекцій без дублювання
Коли ви реалізуєте endpoint для списку (GET /api/v1/tasks), ви майже одразу потрапляєте в ситуацію: сервіс повертає список внутрішніх Task, а назовні ви маєте віддати список TaskSummaryResponse. І технічно можна прямо в контролері написати tasks.stream().map(taskMapper::toSummaryResponse).toList(). Але якщо ви робите так у кожному контролері, воно починає дублюватися — і знову у нас ремонт на кухні.
Є простий компроміс: у mapper додати метод для колекцій. Він залишається дуже коротким і не ховає магію, зате прибирає повторення по проєкту.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class TaskMapper {
public List<TaskSummaryResponse> toSummaryResponseList(List<Task> tasks) {
// Єдина точка перетворення колекції доменних об'єктів у DTO для списку
return tasks.stream()
.map(this::toSummaryResponse) // Повторно використовуємо «одиночний» мапінг
.toList();
}
}
Так, це три рядки. Але ці три рядки економлять вам майбутні «а де у нас ще було таке саме мапінг?». І, що важливіше, вони дають єдине місце, де формується контракт summary-представлення.
Контролер залишається тонким
Найприємніший момент у ручному мапінгу — коли ви бачите, як контролер перестає бути «комбайном» і перетворюється на акуратну точку входу. Він приймає request DTO, віддає його mapperʼу, кличе сервіс і знову кличе mapper — але вже для відповіді.
Нижче — ескіз TaskController, який показує три сценарії: list, detail і create. Він демонстраційний, тому без тонких нюансів і без спроб «обробити все на світі».
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.api.mapper.TaskMapper;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
public TaskController(TaskService taskService, TaskMapper taskMapper) {
// Контролер тримаємо «тонким»: впроваджуємо залежності, бізнес-логіку сюди не тягнемо
this.taskService = taskService;
this.taskMapper = taskMapper;
}
@GetMapping
public List<TaskSummaryResponse> getTasks() {
// Контролер отримує доменні об'єкти від сервісу...
List<Task> tasks = taskService.findAll();
// ...і віддає назовні DTO через mapper
return taskMapper.toSummaryResponseList(tasks);
}
@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
// Пошук і перевірка існування — зона відповідальності сервісу
Task task = taskService.findById(taskId);
return taskMapper.toDetailsResponse(task);
}
@PostMapping
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
// Із вхідного DTO робимо «чернетку» доменної моделі
Task draft = taskMapper.toDraftTask(request);
// Сервіс уже вирішує все предметне: id, статус, збереження тощо
Task created = taskService.create(draft);
// Назовні повертаємо DTO, а не доменний об'єкт
return taskMapper.toDetailsResponse(created);
}
}
У цьому коді добре видно, що контролер займається маршрутизацією: хто що викликав, куди передати, що повернути. А mapper — перекладом. Це дуже здорова звичка: контролер не має знати, скільки полів у нас у TaskDetailsResponse і як вони заповнюються. Він має знати лише те, що є DTO і є mapper.
Саме тут спливає наступне практичне запитання: якщо draft прийшов із request DTO, то які поля взагалі допустимі від клієнта, а які сервер зобов’язаний виставити сам.
7. Межі відповідальності mapper
З ручним мапінгом є кумедний ризик: ви починаєте з чесного «скопіював три поля», а через тиждень у mapper додається «якщо статус такий, то додай прапорець», потім «сходи в репозиторій», потім «порахуйте щось», і ось у вас уже не mapper, а мінісервіс із таємним ходом у бізнес-логіку.
Щоб цього не сталося, корисно тримати просту дисципліну. Mapper має право перетворювати один об’єкт на інший, перейменовувати поля, викидати зайве, збирати складену відповідь із полів одного об’єкта. Але mapper не має вирішувати, чи існує задача, чи можна її змінювати, які статуси дозволені, і вже точно не має ходити в репозиторій.
Нижче — коротка «пам’ятка» у вигляді таблиці. Її можна подумки приклеїти поруч із монітором або хоча б поруч із кавою.
| Дія | У mapper можна? | Чому |
|---|---|---|
| Скопіювати поле title у відповідь | Так | це чиста зміна форми даних |
| Викинути внутрішнє поле з відповіді | Так | mapper і потрібен, щоб внутрішнє не витікало назовні |
| Конвертувати enum у рядок (якщо потрібно) | Так | це все ще про форму даних |
| Генерувати id | Зазвичай ні | це вже відповідальність сервісу/домену |
| Вирішувати, який статус поставити під час створення | Зазвичай ні | це вже предметне рішення, а не переклад |
| Ходити в репозиторій або сервіс всередині mapper | Ні | mapper стане «прихованою магією» і ускладнить налагодження |
Ця дисципліна здається занудною рівно доти, доки ви не починаєте налагоджувати баг, де «чомусь у відповіді поле status стало таким». Якщо логіка схована в mapperʼі та перемішана з правилами бізнесу, ви шукатимете причину довго й сумно. А якщо mapper простий — причина завжди десь поруч.
8. Типові помилки під час ручного мапінгу
Помилка № 1: розмазувати мапінг по контролерах «тому що так швидше».
Це справді швидше перші дві години, а потім — дуже повільно наступні два тижні. Ви отримуєте дублювання та розбіжності: один endpoint повертає status, інший забуває, третій повертає description у summary, а клієнт уже не розуміє, чого йому очікувати. Один mapper-клас — це нудно, але дуже надійно.
Помилка № 2: робити один універсальний метод «toResponse» і намагатися ним покрити list і detail.
Спочатку здається: «ну що там, один і той самий Task». Але list і detail — різні контракти. І навіть якщо зараз вони однакові, вони почнуть розходитися, щойно ви додасте кілька полів або захочете зробити список легшим. Два методи (toSummaryResponse, toDetailsResponse) — це не зайве, а страховка від майбутнього хаосу.
Помилка № 3: ховати важливі перетворення в «розумних» копіювальниках полів.
Існує спокуса використовувати утиліти на кшталт «копіювати всі однакові поля автоматично». На перших кроках це перетворює мапінг на чорну скриньку: новачку стає незрозуміло, які поля реально йдуть назовні. А ще це погіршує контроль контракту: додали поле у внутрішню модель — воно раптом зʼявилося в API. Ми свідомо обираємо ручний, явний шлях.
Помилка № 4: додавати бізнес-рішення в mapper «бо зручно».
Mapper — погане місце для рішень на кшталт «якщо задача прострочена, виставимо статус». Це вже поведінка предметної області, і вона має бути в сервісі. Інакше ви отримаєте змішування шарів, а потім — несподівані розбіжності: один endpoint віддає об’єкт через mapper і бачить «розумні» правила, інший endpoint формує відповідь інакше, і правила не застосовуються.
Помилка № 5: забути, що mapper — частина публічного контракту, а отже потребує акуратності.
Коли ви змінюєте мапінг, ви змінюєте те, що побачить клієнт. Іноді це здається невинним: «перейменую поле», «приберу поле, воно не потрібне». Але для клієнта це виглядає як зламна зміна контракту. Тому навіть у навчальному проєкті корисно ставитися до mapperʼа як до місця, де контракт стає реальністю: змінюємо обережно, усвідомлено і лише тоді, коли розуміємо наслідки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ