JavaRush /Курсы /Java Server /Ручной mapping: domain ↔ DTO

Ручной mapping: domain ↔ DTO

Java Server
19 уровень , 2 лекция
Открыта

1. Ручной mapping: смысл и польза

Разделить domain и DTO — это только половина дела. Когда вы впервые делаете это руками, кажется, что вы сами себе усложнили жизнь: раньше был «один объект», а теперь вдруг появляется переводчик, который таскает поля туда‑сюда. Но в реальном backend‑коде это не усложнение ради сложности — это нормальная плата за то, чтобы проект жил дольше одного вечера и не разваливался при первом изменении требований (а требования, как известно, меняются чаще, чем погода).

Представьте, что ReadingListItem — это «как приложение реально хранит и понимает запись списка чтения», а ReadingItemResponse — это «как клиент хочет видеть данные». Иногда они совпадают (особенно в учебном проекте), но важно, что они не обязаны совпадать. Внутри вы можете захотеть хранить дополнительные поля (например, внутренний технический флаг, дату создания, причину отказа от книги), а наружу отдавать только то, что нужно клиенту. Или наоборот: клиенту нужна удобная форма ответа (обертка items + count), а внутри вы храните просто List<ReadingListItem> или Map<Long, ReadingListItem>.

И вот тут появляется ключевая идея: mapping — это «контрольный пункт» на границе слоев. Он помогает:

  • аккуратно превращать входящий request DTO в доменную сущность (или обновлять её);
  • формировать response DTO из доменной сущности;
  • держать все преобразования в одном предсказуемом месте, а не размазывать по service, repository и «кускам кода в разных ветках if-ов».

Небольшая самоирония: да, mapping — это тот самый «переводчик», который всем нужен, но никто не хотел нанимать. Зато без него через месяц вы будете переводчиком сами, вручную, в десяти местах и без выходных.

2. Граница слоев и место маппинга

Если сказать максимально коротко, repository хранит домен, handler разговаривает с внешним миром, а service делает прикладную работу. В этой фразе много смыслов, и сейчас нам важно вытащить один конкретный: маппинг не должен жить в репозитории, а «транспортная форма данных» не должна протекать внутрь слоя хранения.

Чтобы не говорить абстрактно, давайте зафиксируем это в виде маленькой карты. Она не про Spring и не про «правильную архитектуру на все времена», а про здравый смысл в нашем ReadLater Starter.

Слой (роль) С чем работает Что не должен знать
readinglist.http request/response DTO, статусы, “что ответить клиенту” как именно данные хранятся внутри
readinglist.service доменные правила, операции над ReadingListItem детали HTTP и JSON
readinglist.repository только ReadingListItem DTO, JSON, response-модели

Это можно представить и схемой (очень «по‑простому», но зато наглядно):

flowchart LR
  A["Request DTO
Create/Update"] --> B["Mapper"] B --> C["Domain
ReadingListItem"] C --> D["Service"] D --> E["Repository"] C --> B2["Mapper"] B2 --> F["Response DTO
ReadingItemResponse / ReadingListResponse"]

Теперь главный практический вопрос: где физически должен находиться этот “Mapper”? У нас есть три популярные ошибки и один хороший компромисс.

Если вы маппите прямо в repository, репозиторий начинает «понимать внешний контракт», а это значит, что любой чих в API будет тянуть изменения в слой хранения. Получается клейкая масса, а не слои. Если вы маппите хаотично в каждом handler’е, то обработчики запросов распухают, превращаясь в «контроллеры-гиганты», где смешаны JSON‑контракт, бизнес‑логика и детали преобразований.

Самый адекватный вариант для нашего уровня — держать небольшой отдельный класс-маппер внутри фичи readinglist (например, рядом со service). Он зависит и от domain, и от DTO, но при этом не заставляет repository и domain «тащить» DTO к себе.

3. Минимальный ReadingListMapper

Сейчас мы сделаем то, что часто кажется скучным, но потом экономит часы: создадим маленький класс, который отвечает только за преобразования. Важно удержаться от соблазна сделать «универсальный маппер на все случаи жизни». Мы не строим MapStruct, AutoMapper и «мини-Spring», мы делаем очевидный код, который можно прочитать без мантр и шаманского бубна.

Логика будет такая: в одном месте лежат методы toDomain, applyUpdate, toResponse, toListResponse. Никакой магии, никакой рефлексии, просто копирование полей — но в одном месте и одинаковым образом.

package com.example.readlater.readinglist.service;

public class ReadingListMapper {
    // Здесь соберём все преобразования фичи reading list:
    // toDomain(...), applyUpdate(...), toResponse(...), toListResponse(...).
}

Да, пока это выглядит как «скелет». Но методически важно начать именно со скелета: вы создали место, куда будет стекаться вся логика преобразований. В большом проекте это место — отличная точка для быстрых правок и ревью: “Ага, контракт поменялся — идем в маппер”.

Небольшой нюанс про стиль: маппер может быть final, методы могут быть public, можно сделать их static. В нашем проекте обычный объект удобен тем, что его легко передавать через конструкторы и не городить вокруг этого отдельную магию.

4. Request DTO → domain

Когда данные приходят «снаружи», они почти всегда в форме request DTO. В локальном API это будет JSON, но сейчас нам вообще не важно, откуда появился request DTO: из теста, из временного консольного режима, из будущего HTTP-обработчика — всё равно дальше у нас один путь: превратить это в доменную сущность и работать уже с ней.

Начнем с создания. Для создания домену нужен id, а request DTO обычно id не содержит (и это правильно: клиент не должен выбирать наши локальные идентификаторы). Поэтому id приходит отдельным параметром — например, из генератора AtomicLong (это будет позже, в слое repository/service). Мапперу всё равно, откуда взялся id, он просто получает число.

Файл: ReadingListMapper.java

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.CreateReadingItemRequest;

public ReadingListItem toDomain(long id, CreateReadingItemRequest req) {
    // id приходит "снаружи" (генератор/репозиторий), а маппер просто аккуратно собирает домен
    return new ReadingListItem(
            id, req.title(), req.author(),
            req.status(), req.externalId(), req.comment()
    );
}

Обратите внимание: здесь нет второй копии проверок title/author/status и нет отдельной нормализации externalId/comment. Маппер передаёт данные в канонический конструктор, а домен уже сам решает, можно ли вообще существовать в таком состоянии.

Теперь про обновление. Полное обновление (UpdateReadingItemRequest) означает «замени все поля». И здесь нам как раз не нужен набор из пяти конкурирующих мини-сеттеров: у домена уже есть update, который делает full replace одним вызовом. Мапперу остаётся вызвать его и не размазывать одну и ту же логику по проекту.

Файл: ReadingListMapper.java

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.UpdateReadingItemRequest;

public void applyUpdate(ReadingListItem item, UpdateReadingItemRequest req) {
    // Полное обновление делегируем каноническому domain API
    item.update(
            req.title(),
            req.author(),
            req.status(),
            req.externalId(),
            req.comment()
    );
}

Для узкого сценария, где меняется только статус, доменный changeStatus никуда не делся. Просто здесь мы описываем именно full replace, а не PATCH‑подобное изменение одного поля.

Да, это «копирование полей». И да, это нормально. Боль начинается не от копирования, а от копирования в пяти местах разными способами.

5. Domain → response DTO

Если request DTO — это “что мы приняли”, то response DTO — это “что мы обещали отдать”. И именно здесь очень легко (особенно новичку) сделать кучу маленьких, но неприятных ошибок: забыть поле, отдать лишнее, по-разному отдать один объект в разных endpoint-ах, вернуть “голый список” там, где договорились о items + count.

Начнем с самого простого: из доменного объекта сделать ReadingItemResponse. Это обычно чистая функция: взяли домен, собрали DTO.

Файл: ReadingListMapper.java

import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.ReadingItemResponse;

public ReadingItemResponse toResponse(ReadingListItem item) {
    // Явно перечисляем поля: это проще читать и проще проверять на ревью
    return new ReadingItemResponse(
            item.getId(), item.getTitle(), item.getAuthor(),
            item.getStatus(), item.getExternalId(), item.getComment()
    );
}

Тут вы, скорее всего, заметите: «А зачем нам вообще response DTO, если поля те же?» Ответ простой: сегодня поля те же, завтра — не те же. И лучше, чтобы “завтра” не заставило вас менять 20 мест в коде. Это “страховка от будущего”, но не в стиле “enterprise на максималках”, а в стиле “не наступать на грабли босиком”.

Теперь список. Мы договорились, что список — это ReadingListResponse(items, count), а не “голый List”. Значит, маппер должен уметь собирать и такую форму.

Файл: ReadingListMapper.java

import com.example.readlater.readinglist.dto.ReadingListResponse;
import com.example.readlater.readinglist.domain.ReadingListItem;

import java.util.List;

public ReadingListResponse toListResponse(List<ReadingListItem> items) {
    // Собираем список DTO через единый toResponse, чтобы не плодить "копирование в разных местах"
    var dtos = items.stream().map(this::toResponse).toList();

    // count берём из фактического списка, который отдаём клиенту (чтобы всегда было "честно")
    return new ReadingListResponse(dtos, dtos.size());
}

Заметьте хорошую мелочь: count считается от фактического списка, который мы отдаём клиенту. Если позже появится фильтрация или преобразования, count всегда будет честным.

6. Нормализация и граница ответственности

На этом месте легко скатиться во вторую копию доменных правил: ещё одна проверка title, ещё одна версия работы со status, ещё одна нормализация optional-полей уже в маппере. Но тогда у вас появляются два конкурирующих источника истины: один в домене, другой — в переводчике.

В нашем ReadLater канон простой: обязательные поля, null-проверки и нормализация optional-значений живут в ReadingListItem. Маппер может аккуратно передать данные дальше, но не должен изобретать вторую систему инвариантов. Если когда-нибудь захочется добавить мелкий transport-level cleanup вроде trim(), это всё равно не отменяет доменную защиту.

7. Mapping без мини‑фреймворка

На этом месте у многих появляется мысль: “А можно сделать универсальный Mapper<T, R>? А можно рефлексией пройтись по полям? А давайте сделаем Map<String, Object> и будем маппить динамически?”. Теоретически можно. Практически вы очень быстро построите «домик на песке», который сложно отлаживать и который работает ровно до первой странной ситуации.

Для нашего уровня (и вообще для большинства обычных backend‑проектов на начальном этапе) ручной mapping хорош именно тем, что он прозрачный. Вы открыли файл, увидели ровно те поля, которые копируются, поняли, где может быть ошибка. Он не «умный», зато честный.

Чтобы сохранить эту честность, полезно придерживаться нескольких правил, но давайте без списков “заповедей”. Просто держите в голове: маппер должен быть маленьким; в нем должны быть операции преобразования, а не бизнес-решения; он не должен тянуть в себя repository; он не должен знать ничего про HTTP и JSON. Если вы поймали себя на том, что в маппере появляются сообщения вида “если у пользователя такой-то статус, то поменяем его автоматически” — вы уже съехали в бизнес-логику.

И ещё важный момент: mapping должен быть единым. Если вы в одном месте делаете externalId.trim(), а в другом — нет, у вас появится “призрачный баг”: одинаковые запросы будут вести себя по-разному. Поэтому лучше иметь один маппер и пользоваться им везде, чем “чуть-чуть маппинга там, чуть-чуть тут”.

8. Типичные ошибки при ручном mapping

Ручной mapping кажется простым, и в этом его ловушка: когда что-то простое размазывается по проекту, оно превращается в кашу быстрее всего. Поэтому в конце лекции полезно зафиксировать типичные грабли. Это не “стыдно”, это нормально: почти все наступали на них, просто кто-то делал это на учебном проекте, а кто-то — в проде в пятницу вечером.

Ошибка №1: маппить прямо в repository.
Выглядит удобно: “репозиторий же сохраняет — пусть и преобразует”. Но тогда слой хранения начинает зависеть от DTO, а значит, любое изменение внешнего контракта будет ломать внутренности. В итоге репозиторий перестает быть репозиторием и становится «комбайном», который знает слишком много.

Ошибка №2: повторять маппинг в каждой ветке обработчика.
Сегодня у вас один endpoint и две ветки if, завтра — пять endpoint-ов и двадцать веток, и в каждой руками собирается ReadingItemResponse. Потом вы добавляете поле и забываете обновить одну ветку. И здравствуйте, “почему в одном ответе поле есть, а в другом нет”.

Ошибка №3: позволять DTO “протечь” в сервисный слой без причины.
Если service начинает принимать CreateReadingItemRequest, он становится привязан к транспортному контракту. Иногда это допустимо в маленьком проекте, но как только появится второй интерфейс (например, другой входной режим или другой транспорт), вы почувствуете, что сервису стало тесно. Лучше, чтобы сервис работал с доменом, а DTO оставались ближе к границам.

Ошибка №4: смешивать маппинг и валидацию в один большой “бог-метод”.
Сегодня вы просто копировали поля, завтра вы там же проверяете обязательность, послезавтра — проверяете уникальность externalId, а потом ещё и решаете, какой HTTP-статус вернуть. В итоге маппер становится центром вселенной, и любое изменение превращается в риск. Граница простая: маппер формирует объекты, а решения “можно/нельзя” живут в домене/сервисе/валидации.

Ошибка №5: пытаться сделать “универсальный маппер” с рефлексией.
Иногда это кажется экономией кода, но в учебном проекте вы почти наверняка потеряете больше времени на отладку, чем сэкономите. Явный mapping в 5–10 строк обычно читается быстрее и ломается предсказуемее. А предсказуемость в backend-коде — это не скука, это счастье.

1
Задача
Java Server, 19 уровень, 2 лекция
Недоступна
Явный mapping от create-request к домену и ответу
Явный mapping от create-request к домену и ответу
1
Задача
Java Server, 19 уровень, 2 лекция
Недоступна
applyUpdate и toListResponse
applyUpdate и toListResponse
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ