1. Неизбежный mapping после DTO
Как только у create, list, detail и update появляются свои DTO, образуется новая честная проблема: данные больше не могут “протечь сами” из JSON в домен и обратно. Их нужно переводить из одной формы в другую. Это не «лишний слой», а плата за контроль над контрактом. Как в аэропорту: можно лететь с ручной кладью (одна модель на всё), а можно с чемоданом и документами (DTO + mapping) — чуть сложнее, зато предсказуемо.
Само слово mapping часто звучит как что-то из мира картографии и магии: «мы замапим, оно замапится». На практике это обычная инженерная работа: взять поля из TaskCreateRequest, положить их в доменную Task (или хотя бы в доменный объект, с которым работает сервис), а потом — наоборот — взять Task и собрать TaskSummaryResponse или TaskDetailsResponse.
Важно зафиксировать: mapping — это не бизнес-логика. Это «перевод формы данных». Если сервис решает что делать, то mapper решает как упаковать это в нужную форму.
Чтобы картинка сложилась, полезно держать в голове простой поток:
flowchart TD A[JSON request] --> B[TaskCreateRequest] B --> C[TaskMapper] C --> D["Task (internal)"] D --> E[TaskService] E --> F["Task (internal)"] F --> G[TaskMapper] G --> H[TaskDetailsResponse] H --> I[JSON response]
Это и есть обычный рабочий маршрут API-границы: request DTO приходит снаружи, превращается во внутренний объект, проходит через сервис и затем собирается в response DTO.
Здесь нет «секретного Spring-механизма». Jackson превращает JSON в request DTO и обратно, а всё, что между DTO и внутренней моделью, — наша ответственность.
2. Два направления mapping: вход и выход
Когда начинаешь писать mapping, есть соблазн «сделать один универсальный метод» и жить счастливо. Но реальность обычно быстро даёт по рукам: входной mapping и выходной mapping — это разные сущности, и смешивать их неудобно даже в голове, не то что в коде.
Входной mapping отвечает на вопрос: «как из того, что прислал клиент, получить внутренний объект, с которым удобно работать внутри приложения?». Он почти всегда более “узкий”, потому что клиент не должен присылать всё подряд.
Выходной mapping отвечает на вопрос: «как из внутреннего объекта собрать то представление, которое обещает конкретный endpoint?». И тут появляется важная деталь: для list и для detail это разные представления, значит и mapping будет разный.
Удобно зафиксировать это в небольшой таблице, чтобы мозг не пытался склеить две разные задачи в одну:
| Направление | Откуда → куда | Пример в Task Tracker API | Что главное |
|---|---|---|---|
| Input mapping | request DTO → internal model | TaskCreateRequest → Task (черновик) | переносим только то, что клиент реально присылает |
| Output mapping (summary) | internal model → response DTO | Task → TaskSummaryResponse | кратко, удобно для списка |
| Output mapping (details) | internal model → response DTO | Task → TaskDetailsResponse | подробнее, удобно для одного ресурса |
Эта «трёхчастная» схема сильно упрощает жизнь: вы не ищете “идеальный DTO”, вы делаете несколько простых преобразований, каждое из которых отвечает за одну понятную форму.
3. Где должен жить mapper
На маленьких примерах очень хочется сделать так: «в контроллере получил Task, тут же руками собрал TaskDetailsResponse и вернул». Это работает, но это похоже на ситуацию «поживу на кухне, пока ремонт». Ремонт, как мы знаем, иногда длится годами.
Когда mapping размазан по контроллерам, вы получаете два неприятных эффекта. Во-первых, контроллер перестаёт быть тонким: он начинает заниматься рутинным перекладыванием данных, а потом туда же «чуть-чуть логики», «чуть-чуть условий», и вы уже незаметно получаете жирный пирожок вместо тонкого слоя. Во-вторых, одинаковый mapping начинает дублироваться: один и тот же Task вы превращаете в TaskSummaryResponse в трёх местах, и везде это чуть-чуть по-разному (а потом удивляетесь, почему API «плывёт»).
В нашем проекте это решается аккуратно и предсказуемо: мы кладём mapper в пакет com.example.tasktracker.api.mapper. Это подчёркивает мысль: mapping относится к API-границе, то есть к месту, где внутренний мир встречается с внешним контрактом.
Минимальный каркас mapper’а выглядит так:
package com.example.tasktracker.api.mapper;
import org.springframework.stereotype.Component;
@Component // Делаем mapper Spring-bean'ом, чтобы внедрять его без ручного new
public class TaskMapper {
// Mapper — это "переводчик" между DTO и внутренней моделью на границе API
// здесь будут методы mapping
}
Аннотация @Component нужна не для красоты: так mapper станет Spring-bean’ом, и мы сможем аккуратно внедрять его в контроллеры (или другие компоненты API-слоя), не создавая руками new TaskMapper() в каждом классе.
4. Input mapping: TaskCreateRequest → черновик Task
Сейчас мы сделаем важную вещь: научимся собирать внутреннюю модель из request DTO так, чтобы mapping был простым и «без сюрпризов». На этом этапе нам не нужно придумывать хитрые правила. Наша цель — просто честно скопировать поля, которые пришли от клиента, и получить объект, с которым будет работать сервис.
Чтобы не утонуть в полях, ниже оставим только тот срез 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-mapping
public void setTitle(String title) { this.title = title; }
public void setDescription(String description) { this.description = description; }
public void setAssigneeName(String assigneeName) { this.assigneeName = assigneeName; }
}
И вот mapping в 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. Output mapping: 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;
@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()
);
}
}
Если вы сейчас подумали «а почему тут так много одинакового?», то поздравляю: вы думаете как разработчик. Но это тот случай, когда одинаковость — это хорошо. Мы хотим, чтобы mapping был скучным. Скучный mapping обычно означает предсказуемый контракт, а предсказуемый контракт — это счастье для клиента и меньше боли для нас.
6. Контроллер и коллекции
Mapping коллекций без дублирования
Когда вы реализуете list 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) // Переиспользуем "одиночный" mapping
.toList();
}
}
Да, это три строчки. Но эти три строчки экономят вам будущие «а где у нас ещё был такой же mapping?». И, что важнее, они дают единое место, где формируется контракт summary-представления.
Контроллер остаётся тонким
Самый приятный момент в ручном mapping — когда вы видите, как контроллер перестаёт быть «комбайном» и превращается в аккуратную точку входа. Он принимает 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
С ручным mapping есть забавный риск: вы начинаете с честного «скопировал три поля», а через неделю в mapper добавляется «если статус такой, то добавь флаг», потом «сходи в репозиторий», потом «посчитай что-то», и вот у вас уже не mapper, а мини-сервис с потайным ходом в бизнес-логику.
Чтобы этого не случилось, полезно держать простую дисциплину. Mapper имеет право превращать один объект в другой, переименовывать поля, выкидывать лишнее, собирать составной ответ из полей одного объекта. Но mapper не должен решать, существует ли задача, можно ли её менять, какие статусы разрешены, и уж точно не должен ходить в репозиторий.
Ниже — короткая «памятка» в виде таблицы. Её можно мысленно приклеить рядом с монитором (или хотя бы рядом с кофе).
| Действие | В mapper можно? | Почему |
|---|---|---|
| Скопировать поле title в ответ | Да | это чистая смена формы данных |
| Выкинуть внутреннее поле из ответа | Да | mapper и нужен, чтобы внутреннее не утекало наружу |
| Конвертировать enum в строку (если нужно) | Да | это всё ещё про форму данных |
| Генерировать id | Обычно нет | это уже ответственность сервиса/домена |
| Решать «какой статус поставить при создании» | Обычно нет | это уже предметное решение, а не перевод |
| Ходить в репозиторий/сервис внутри mapper | Нет | mapper станет «скрытой магией» и усложнит отладку |
Эта дисциплина кажется занудной ровно до тех пор, пока вы не начинаете дебажить баг, где «почему-то в ответе поле status стало таким». Если логика спрятана в mapper’е и перемешана с правилами бизнеса, вы будете искать причину долго и с грустью. А если mapper простой — причина всегда где-то рядом.
8. Типичные ошибки при ручном mapping
Ошибка №1: размазывать mapping по контроллерам “потому что так быстрее”.
Это действительно быстрее первые два часа, а потом — очень медленно следующие две недели. Вы получаете дублирование и расхождения: один endpoint возвращает status, другой забывает, третий возвращает description в summary, а клиент уже не понимает, что ему ожидать. Один mapper-класс — это скучно, но очень надёжно.
Ошибка №2: делать один универсальный метод “toResponse” и пытаться им покрыть list и detail.
Поначалу кажется: «ну чего там, один и тот же Task». Но list и detail — разные контракты. И даже если сейчас они одинаковые, они начнут расходиться, как только вы добавите пару полей или захотите сделать список легче. Два метода (toSummaryResponse, toDetailsResponse) — это не “лишнее”, это страховка от будущего хаоса.
Ошибка №3: прятать важные преобразования в “умные” копировщики полей.
Существует соблазн использовать утилиты вроде “копировать все одинаковые поля автоматически”. На первых шагах это превращает mapping в чёрный ящик: новичку становится непонятно, какие поля реально уходят наружу. А ещё это ухудшает контроль контракта: добавили поле во внутреннюю модель — оно внезапно появилось в API. Мы сознательно выбираем ручной, явный путь.
Ошибка №4: добавлять бизнес-решения в mapper “потому что удобно”.
Mapper — плохое место для решений вида «если задача просрочена, выставим статус». Это уже поведение предметной области, и оно должно быть в сервисе. Иначе вы получите смешение слоёв, а затем — неожиданные расхождения: один endpoint отдаёт объект через mapper и видит “умные” правила, другой endpoint формирует ответ иначе и правила не применяются.
Ошибка №5: забыть, что mapper — часть публичного контракта, а значит требует аккуратности.
Когда вы меняете mapping, вы меняете то, что увидит клиент. Иногда это кажется невинным: “переименую поле”, “уберу поле, оно не нужно”. Но для клиента это выглядит как ломающее изменение контракта. Поэтому даже в учебном проекте полезно относиться к mapper’у как к месту, где контракт становится реальностью: меняем осторожно, осознанно и только тогда, когда понимаем последствия.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ