JavaRush /Курси /Java Server /Ручний мапінг: domain ↔ DTO

Ручний мапінг: domain ↔ DTO

Java Server
Рівень 19 , Лекція 2
Відкрита

1. Ручний мапінг: зміст і користь

Розділити domain і DTO — це лише половина справи. Коли ви вперше робите це вручну, може здатися, що ви самі собі ускладнили життя: раніше був «один об’єкт», а тепер раптом зʼявляється перекладач, який тягатиме поля туди й назад. Але в реальному бекенд-коді це не ускладнення заради складності — це нормальна плата за те, щоб проєкт жив довше одного вечора й не розвалювався при першій зміні вимог (а вимоги, як відомо, змінюються частіше, ніж погода).

Уявіть, що ReadingListItem — це «як застосунок реально зберігає й розуміє запис списку читання», а ReadingItemResponse — це «як клієнт хоче бачити дані». Іноді вони збігаються, особливо в навчальному проєкті, але важливо інше: вони не зобов’язані збігатися. Усередині ви можете зберігати додаткові поля, наприклад внутрішній технічний прапорець, дату створення або причину відмови від книжки, а назовні віддавати лише те, що потрібно клієнту. Або навпаки: клієнту потрібна зручна форма відповіді — обгортка items + count, а всередині ви зберігаєте просто List<ReadingListItem> або Map<Long, ReadingListItem>.

І ось тут зʼявляється ключова ідея: мапінг — це «контрольний пункт» на межі шарів. Він допомагає:

  • акуратно перетворювати вхідний request DTO у доменну сутність або оновлювати її;
  • формувати response DTO з доменної сутності;
  • тримати всі перетворення в одному передбачуваному місці, а не розмазувати їх по service, repository і «шматках коду в різних гілках if-ів».

Так, мапер — це той самий «перекладач», який усім потрібен, але ніхто не хотів наймати. Зате без нього через місяць ви самі станете перекладачем — вручну, у десяти місцях і без вихідних.

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
Створення / оновлення"] --> B["Мапер"] B --> C["Домен
ReadingListItem"] C --> D["Service"] D --> E["Repository"] C --> B2["Мапер"] 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. Мапінг без міні-фреймворка

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

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

Щоб зберегти цю чесність, корисно дотримуватися кількох правил, але давайте без списку «заповідей». Просто тримайте в голові: мапер має бути маленьким; у ньому мають бути операції перетворення, а не бізнес-рішення; він не має тягнути в себе repository; він не має знати нічого про HTTP і JSON. Якщо ви зловили себе на тому, що в мапері зʼявляються повідомлення на кшталт «якщо в користувача такий-то статус, то змінимо його автоматично», — ви вже зʼїхали в бізнес-логіку.

І ще важливий момент: мапінг має бути єдиним. Якщо ви в одному місці робите externalId.trim(), а в іншому — ні, у вас зʼявиться «примарний» баг: однакові запити поводитимуться по-різному. Тому краще мати один мапер і користуватися ним усюди, ніж «трошки мапінгу там, трошки тут».

8. Типові помилки під час ручного мапінгу

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

Помилка №1: мапити прямо в repository.
Виглядає зручно: «репозиторій же зберігає — нехай і перетворює». Але тоді шар зберігання починає залежати від DTO, а отже, будь-яка зміна зовнішнього контракту ламатиме внутрішню частину. У підсумку репозиторій перестає бути репозиторієм і стає «комбайном», який знає занадто багато.

Помилка №2: повторювати мапінг у кожній гілці обробника.
Сьогодні у вас один endpoint і дві гілки if, завтра — пʼять endpoint-ів і двадцять гілок, і в кожній вручну збирається ReadingItemResponse. Потім ви додаєте поле й забуваєте оновити одну гілку. І маємо класичне: чому в одній відповіді поле є, а в іншій — ні.

Помилка №3: дозволяти DTO «просочитися» в сервісний шар без причини.
Якщо service починає приймати CreateReadingItemRequest, він стає прив’язаним до транспортного контракту. Іноді це допустимо в маленькому проєкті, але щойно зʼявиться другий інтерфейс, наприклад інший вхідний канал або інший транспорт, ви відчуєте, що сервісу стало тісно. Краще, щоб сервіс працював із доменом, а DTO залишалися ближче до межі.

Помилка №4: змішувати мапінг і валідацію в один великий «бог-метод».
Сьогодні ви просто копіювали поля, завтра там же перевіряєте обов’язковість, післязавтра — унікальність externalId, а потім ще й вирішуєте, який HTTP-статус повернути. У підсумку мапер стає центром всесвіту, і будь-яка зміна перетворюється на ризик. Межа проста: мапер формує об’єкти, а рішення «можна / не можна» живуть у домені, сервісі або валідації.

Помилка №5: намагатися зробити «універсальний мапер» із рефлексією.
Іноді це здається економією коду, але в навчальному проєкті ви майже напевно витратите більше часу на налагодження, ніж заощадите. Явний мапінг у 5–10 рядків зазвичай читається швидше й ламається передбачуваніше. А передбачуваність у бекенд-коді — це не нудьга, це щастя.

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