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 рядків зазвичай читається швидше й ламається передбачуваніше. А передбачуваність у бекенд-коді — це не нудьга, це щастя.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ