1. Корисно розділяти DTO і Domain
Якщо ви тільки починаєте, дуже хочеться сказати: «Ну, прийшов JSON, я його в BookDTO декодував — отже, це і є книга, можна працювати». Це нормальний етап дорослішання — так само, як і збереження пароля в коді на старті, після чого ви вже не жартуєте про безпеку. Проблема в тому, що DTO і доменна модель відповідають на різні запитання.
DTO відповідає на запитання: «Як сервер назвав поля і які типи він туди поклав — або, іноді, не поклав узагалі?». Domain відповідає так: «Що ми вважаємо коректною “книгою” в нашому застосунку і які правила мають виконуватися завжди?». Між ними й живе мапування: акуратний крок, на якому ми перетворюємо «як прийшло» на «як ми готові з цим жити».
Уявіть аналогію: DTO — це коробка з доставкою. На ній може бути написано «Glass», а всередині може бути чашка, може бути тарілка, а може бути… набір болтів. Дякуємо продавцеві. Domain — це вже ваша кухня: туди ви ставите тільки те, що справді є посудом, ціле й чисте. Мапування — це момент розпакування та перевірки, а не приготування.
Модулі та залежності: хто кого імпортує
Зараз у нас проєкт у стилі SwiftPM, де код розбитий на модулі й таргети. Це важливо не через бюрократію, а тому, що межі модулів змушують нас тримати архітектуру у формі — або принаймні не дають їй одразу розповзтися. У світі CLI на Swift це особливо зручно: один модуль запускається, інші працюють як бібліотеки.
Щоб мапування було в правильному місці, треба домовитися про напрям залежностей. Найпростіше правило звучить так: домен (Domain) не має знати про мережевий формат (Networking). Інакше через рік ви захочете замінити API, а вам доведеться «лагодити домен», хоча бізнес-сенс узагалі не змінився.
Нижче — схема, як зазвичай виглядає напрям залежностей у нашому курсі. Я спрощу її, щоб не налякати новачків:
flowchart LR
CLI["LibraryCLI (виконуваний модуль)"] --> APP[Застосунок / сервіс]
APP --> NET[Мережевий шар]
APP --> DOM[Домен]
NET --> DOM
Тут важливий момент: Networking може імпортувати Domain, тому що мережевий шар може повертати доменні моделі назовні. Але Domain не імпортує Networking, тому що доменні сутності не мають залежати від «формату доставки».
Ця стрілочка здається дрібницею, але на ній тримається половина спокою в проєкті.
2. Де тримати правила мапування DTO → Domain
Коли ми говоримо «де тримати правила», ми насправді розв’язуємо два питання: де лежить код і хто має право змінювати ці правила. І тут є кілька популярних підходів, кожен зі своїми компромісами. Я покажу їх як реальні варіанти, а потім оберемо той, який найкраще пасує до нашої архітектури з модулями.
init(dto:) всередині Domain
Це виглядає красиво: Book(dto: dto) — і готово. Але щоб так зробити, домен має знати тип BookDTO. А BookDTO живе в Networking. Виходить, що Domain починає імпортувати Networking, і стрілочки залежностей ламаються.
Іншими словами, ви ніби змушуєте кухню залежати від служби доставки: «Поки курʼєр не визначиться, як він підписує коробку, я не можу зрозуміти, що таке “тарілка”». Звучить дивно — бо це і є дивно.
toDomain() всередині DTO
Другий варіант: залишити DTO «тупим», але поруч, у Networking, додати:
extension BookDTO {
func toDomain() throws -> Book { ... }
}
Тут уже краще: Networking імпортує Domain, залежності залишаються коректними, а домен не знає про мережу. При цьому мапування живе поруч із DTO, тож його зазвичай легко знайти за назвою.
Мінус у тому, що DTO починає «виглядати розумнішим, ніж є». Новачкам легко почати запихати туди зайве: форматування тексту, бізнес-рішення, локалізацію — і непомітно перетворити DTO на «другу доменну модель».
Окремий мапер
Третій варіант — най«нудніший» і тому часто найстійкіший: винести перетворення в окремий тип або файл, наприклад:
- BookDTOMapper
- SearchResponseMapper
- DTOMapping
Це трохи більше літер у коді, зате у вас зʼявляється явне місце, куди ви йдете по правила. І найголовніше: ви можете тримати DTO максимально простими, а доменні моделі — максимально незалежними. Мапер стає «прикордонним контролем» між шарами.
Щоб порівняння було наочним, ось таблиця. Так, таблиця — це не маркований список, а дорослий спосіб сперечатися без крику:
| Підхід | Де лежить код | Головний плюс | Головний ризик | Підходить нам? |
|---|---|---|---|---|
|
Domain | Виклик виглядає красиво | Domain починає залежати від Networking | Скоріше ні |
|
Networking (extension DTO) | Просто знаходити, поруч із DTO | DTO може «розпухнути» логікою | Так, якщо тримати дисципліну |
| Окремий мапер | Networking (окремий файл / тип) | Явні межі, DTO лишається простим | Трохи більше типів і файлів | Так, це наш основний вибір |
У межах курсу ми частіше використовуватимемо або toDomain() через extension, або окремий тип-мапер. Сьогодні я покажу варіант з окремим мапером, бо він краще дисциплінує новачка: «логіка живе тут — і тільки тут».
4. Практика: Domain і DTO для LibraryCLI
Перед тим як мапувати, потрібно розуміти, у що ми мапуємо. У домені ми зазвичай хочемо суворі типи й прості правила: наприклад, у книги має бути непорожній заголовок. Автор може бути невідомим — таке буває. А от порожній заголовок — це вже не «книга», а «помилка даних».
Нижче — невеликий, навмисно спрощений фрагмент домену. Він короткий, але показує суть: домен зберігає сенс, а не те, у якому вигляді це прийшло з мережі.
Domain-модель
Наприклад, Sources/Domain/Book.swift:
import Foundation
public struct BookID: Hashable {
public let rawValue: Int
}
public struct Book {
public let id: BookID
public let title: String
public let author: String
}
Поки без «наворочених» value objects, щоб не відволікатися: нам важливо побачити сам процес мапування. У реальному проєкті ви цілком можете замінити title/author на окремі типи — і ви вже вмієте це робити за матеріалами про value objects і валідацію.
DTO-модель
DTO — це як фотографія паспорта, а не сама людина: формат фіксований, може бути кривий, але це документ, який прийшов ззовні. Якщо сервер надіслав author_name, значить DTO це поле так і має вміти декодувати, навіть якщо нам у Swift хочеться authorName.
Наприклад, Sources/Networking/DTO/BookDTO.swift:
import Foundation
struct BookDTO: Decodable {
let id: Int
let title: String?
let authorName: String?
enum CodingKeys: String, CodingKey {
case id
case title
case authorName = "author_name"
}
}
Зверніть увагу на String?. Це не тому, що ми любимо опціонали, а тому, що мережа іноді надсилає поле, а іноді — «ой, забули». І мережа не зобовʼязана поважати наші очікування — це ми зобовʼязані поважати реальність.
5. Мапер: нормалізація та правила «значення за замовчуванням vs помилка»
Найважливіша частина лекції — зрозуміти, що мапування майже завжди складається з двох підкроків: спочатку ми приводимо дані до нормального вигляду, а потім застосовуємо правила домену. Тобто спершу обрізаємо пробіли, підставляємо значення замість nil, іноді нормалізуємо формат, а вже потім вирішуємо, що дозволено, а що ні.
Ключова розвилка звучить так: якщо даних немає або вони дивні, ми беремо значення за замовчуванням чи повертаємо помилку? Це не математика, а проєктування. І його потрібно ухвалювати свідомо, інакше ви отримаєте застосунок, який мовчки вдає, що все гаразд, доки користувач не помітить книжку з порожньою назвою.
Почнімо з типізованої помилки мапування. Важливо: це не NetworkError і не помилка декодування. Декодування могло пройти успішно — але дані можуть бути неприйнятними для домену.
Наприклад, Sources/Networking/Mapping/DTOMappingError.swift:
import Foundation
enum DTOMappingError: Error {
case emptyTitle(id: Int)
}
Тепер сам мапер.
Наприклад, Sources/Networking/Mapping/BookDTOMapper.swift:
import Foundation
import Domain
struct BookDTOMapper {
func map(_ dto: BookDTO) throws -> Book {
let title = (dto.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { throw DTOMappingError.emptyTitle(id: dto.id) }
let author = (dto.authorName ?? "Невідомо").trimmingCharacters(in: .whitespacesAndNewlines)
return Book(id: BookID(rawValue: dto.id), title: title, author: author)
}
}
Тут ми ухвалили конкретне рішення: порожній title — це помилка, а відсутній автор — «Невідомо». Це не єдине правильне рішення, але воно явне, і це вже величезний плюс.
І маленька ремарка з досвіду: коли ви через рік побачите «Невідомо», ви одразу зрозумієте, що це значення за замовчуванням. А коли ви побачите книгу з порожнім заголовком, то не зрозумієте взагалі нічого й почнете думати, що ваш термінал зламався. Термінал не зламався — зламалася дисципліна даних.
6. Де зберігати мапування в проєкті
На рівні файлів у проєкті є проста й дуже корисна звичка: тримати DTO в папці або групі DTO, а мапування — окремо, наприклад Mapping. Це здається косметикою, але насправді зменшує «випадкову архітектуру».
Коли мапування лежить прямо в BookDTO.swift, новачку легко почати дописувати туди все підряд, бо «ну я ж уже в цьому файлі». Коли мапування окремим типом, мозок отримує сигнал: «зараз ми на межі шарів». Це як табличка «паспортний контроль»: ви ж не дивуєтеся, що там перевіряють документи, а не продають каву. Хоча каву там теж іноді продають, але це вже інша архітектура.
Ще одне правило, яке допомагає не потонути: мапування має бути детермінованим. Це означає, що з одного й того самого DTO він завжди робить один і той самий Domain. Якщо ви раптом почнете всередині мапера використовувати Date() або UUID(), ви перетворите перетворення даних на лотерею, а тестування — на гру «вгадай, що сталося».
7. Мапування колекцій: усе або частково
У реальності API часто повертає масив DTO. І хочеться просто зробити .map(...) і отримати масив доменних моделей. Це нормально, але є два сценарії: «або все, або нічого» та «частковий успіх».
«Або все, або нічого»
Якщо хоча б одна книга не мапується, ми падаємо з помилкою.
import Foundation
import Domain
let mapper = BookDTOMapper()
let dtos: [BookDTO] = [
BookDTO(id: 1, title: " Swift ", authorName: "Apple"),
BookDTO(id: 2, title: nil, authorName: "Nobody")
]
let books = try dtos.map { try mapper.map($0) } // кине помилку на id=2
print(books.count) // не виконається
«Частковий успіх»
Це корисно, коли ви хочете показати користувачеві хоч щось, наприклад 9 книг із 10, але при цьому не втратити інформацію про помилки.
import Foundation
import Domain
let mapper = BookDTOMapper()
let results: [Result<Book, Error>] = dtos.map { dto in
Result { try mapper.map(dto) }
}
let successCount = results.filter {
if case .success = $0 { return true }
return false
}.count
print(successCount) // наприклад, 1
Ми зараз не заглиблюємося в красиві «reduce-конвеєри», щоб не перетворити лекцію про мапування на лекцію про функціональні трюки. Головна ідея така: один DTO → один Result, а далі ви вирішуєте, що робити з успіхами й помилками.
8. Інтеграція в CLI: показуємо Domain, а не DTO
Дуже спокусливо надрукувати DTO «як є» й радіти. Але раз ми вже побудували межу між шарами, давайте закріпимо звичку: назовні, у CLI/UI, ми віддаємо доменні моделі. DTO — це внутрішня частина мережевого шару.
import Foundation
import Domain
func printBook(_ book: Book) {
print("#\(book.id.rawValue): \(book.title) — \(book.author)")
// Наприклад: #1: Swift — Apple
}
І це не тому, що так красивіше. Це тому, що якщо завтра API змінить author_name на writer, ваш CLI не має змінитися взагалі. Він працює з доменним сенсом.
9. Типові помилки при мапуванні DTO → Domain
Помилка № 1: доменна модель імпортує Networking, бо «так простіше викликати init(dto:)».
Це виглядає як маленька поступка заради зручності, але на практиці ламає модульні межі. Через деякий час Domain почне знати про CodingKeys, мережеві дати та поля, яких у домені бути не повинно. І ви втратите головну перевагу домену — незалежність від зовнішнього формату даних.
Помилка № 2: мапування перетворюється на «звалище логіки», бо воно ніби «про дані».
Часто починаються невинні речі: trim(), lowercased(). Потім зʼявляється форматування рядків «для користувача», потім локалізація, потім «якщо автор невідомий — приховати книгу», потім «якщо рік менший за 0 — поставити поточний». У цей момент мапер стає міні-застосунком. Якщо вам потрібне правило бізнес-логіки, воно має жити або в домені, або в сервісі, а мапер має бути прикордонником, а не парламентом.
Помилка № 3: try? у мапуванні й мовчазна втрата причин.
try? зручний, коли вам справді не важлива причина. Але в мапуванні причина часто важлива: ви хочете розрізняти «сервер надіслав порожній title» від «сервер надіслав дивний id». Якщо ви бездумно перетворюєте помилки на nil, то потім годинами гадатимете, чому у вас іноді зникають елементи.
Помилка № 4: значення за замовчуванням ставляться “автоматично”, і домен перестає бути суворим.
Якщо на будь-яку відсутність даних ви відповідаєте значенням за замовчуванням, ви отримуєте систему, у якій помилки даних стають невидимими. Це схоже на машину, де лампочку «Check Engine» заклеїли ізоляційною стрічкою: так, вона більше не дратує, але двигун від цього здоровішим не стає.
Помилка № 5: у мапері зʼявляються випадкові значення (Date(), UUID()) і мапування перестає бути детермінованим.
Сьогодні це «просто працює», а завтра ви захочете написати тест або відтворити баг за логами — і не зможете, бо один і той самий вхід уже дає різні виходи. Мапер має бути максимально передбачуваним: отримав DTO — повернув Domain або помилку, без сюрпризів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ