JavaRush /Курсы /Swift SELF /Маппинг DTO → Domain

Маппинг DTO → Domain

Swift SELF
64 уровень, 2 лекция
Открыта

1. Полезно разделять DTO и Domain

Если вы только начинаете, очень хочется сказать: «Ну пришёл JSON, я его в BookDTO декодировал — значит это и есть книга, поехали работать». Это нормальная стадия взросления, как когда вы сначала храните пароль в коде, а потом перестаёте шутить на тему безопасности. Проблема в том, что DTO и доменная модель отвечают на разные вопросы.

DTO отвечает на вопрос: «Как сервер решил назвать поля, и какие типы он туда положил (или иногда положил)?». Domain отвечает: «Что мы считаем корректной “книгой” в нашем приложении, и какие правила должны всегда выполняться?». Между ними и живёт маппинг: аккуратный шаг, где мы превращаем “как пришло” в “как мы готовы с этим жить”.

Представьте аналогию: DTO — это коробка с доставкой. На коробке может быть написано “Glass”, а внутри может быть кружка, может быть тарелка, а может быть… набор болтов (спасибо, продавец). Domain — это уже ваша кухня: туда вы ставите только то, что реально является посудой, целое и чистое. Маппинг — это момент распаковки и проверки, а не момент готовки.

Модули и зависимости: кто кого импортирует

Сейчас у нас проект в стиле SwiftPM, где код разбит по модулям/таргетам. Это важно не из-за бюрократии, а потому что границы модулей заставляют нас держать архитектуру в форме (или хотя бы не давать ей сразу расползтись). В CLI-мире на Swift это особенно удобно: один модуль запускается, остальные — библиотеки.

Чтобы маппинг был «в правильном месте», надо договориться о направлении зависимостей. Самое простое правило звучит так: домен (Domain) не должен знать о сетевом формате (Networking). Иначе через год вы захотите заменить API, а вам придётся «чинить домен», хотя бизнес‑смысл вообще-то не изменился.

Ниже — схема, как обычно выглядит направление зависимостей в нашем курсе (упрощённо, без всех слоёв, чтобы не распугать новичков):

flowchart LR
    CLI["LibraryCLI (Executable)"] --> APP[Application/Service]
    APP --> NET[Networking]
    APP --> DOM[Domain]
    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 в “вторую доменную модель”.

Отдельный mapper

Третий вариант — самый «скучный» и потому часто самый устойчивый: вынести преобразование в отдельный тип/файл, например:

  • BookDTOMapper
  • SearchResponseMapper
  • DTOMapping

Это чуть больше букв в коде, зато у вас появляется явное место, куда вы идёте за правилами. И самое главное: вы можете держать DTO максимально простыми, а доменные модели — максимально независимыми. Mapper становится «пограничным контролем» между слоями.

Чтобы сравнение было наглядным, вот таблица (да, таблица — это не bullet list, это взрослый способ спорить без крика):

Подход Где лежит код Главный плюс Главный риск Подходит нам?
init(dto:)
Domain Вызов выглядит красиво Domain начинает зависеть от Networking Скорее нет
toDomain()
Networking (extension DTO) Просто находить, рядом с DTO DTO может «распухнуть» логикой Да, если держать дисциплину
Отдельный Mapper Networking (отдельный файл/тип) Явные границы, DTO остаётся простым Чуть больше типов/файлов Да, это наш основной выбор

В рамках курса мы будем чаще использовать либо toDomain() через extension, либо отдельный mapper‑тип. Сегодня я покажу вариант с отдельным mapper’ом, потому что он лучше дисциплинирует новичка: “логика живёт здесь — и только здесь”.

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
}

Пока без fancy‑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?. Это не «потому что мы любим optional’ы», а потому что сеть иногда присылает поле, а иногда “ой, забыла”. И сеть не обязана уважать наши ожидания — это мы обязаны уважать реальность.

5. Mapper: нормализация и правила «дефолт vs ошибка»

Самая важная часть лекции — понять, что маппинг почти всегда состоит из двух подшагов: сначала мы приводим данные к нормальному виду (trim, замена nil, иногда приведение формата), а потом применяем правила домена: что допускается, а что нет.

Ключевая развилка звучит так: если данных нет или они странные, мы берём дефолт или возвращаем ошибку? Это не «математика», это дизайн. И его нужно принимать осознанно, иначе вы получите приложение, которое “тихо делает вид, что всё ок”, пока пользователь не заметит книжку с пустым названием.

Начнём с типизированной ошибки маппинга. Важно: это не NetworkError и не ошибка декодирования. Декодирование могло пройти успешно — но данные могут быть неприемлемы для домена.

Пример (например, Sources/Networking/Mapping/DTOMappingError.swift):

import Foundation

enum DTOMappingError: Error {
    case emptyTitle(id: Int)
}

Теперь сам mapper.

Пример (например, 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 ?? "Unknown").trimmingCharacters(in: .whitespacesAndNewlines)
        return Book(id: BookID(rawValue: dto.id), title: title, author: author)
    }
}

Здесь мы приняли конкретное решение: пустой title — ошибка, а отсутствующий автор — “Unknown”. Это не единственно верное решение, но оно явное, и это уже огромный плюс.

И маленькая ремарка с опытом: когда вы через год увидите “Unknown”, вы сразу поймёте, что это дефолт. А когда вы увидите книгу с пустым заголовком, вы не поймёте вообще ничего и начнёте думать, что ваш терминал сломался. Терминал не сломался, сломалась дисциплина данных.

6. Где хранить маппинг в проекте

На уровне файлов в проекте есть простая и очень полезная привычка: держать DTO в папке/группе DTO, а маппинг — отдельно, например Mapping. Это кажется косметикой, но на самом деле уменьшает “случайную архитектуру”.

Когда маппинг лежит прямо в BookDTO.swift, новичку легко начать дописывать туда всё подряд, потому что “ну я же уже в этом файле”. Когда маппинг отдельным типом, мозг получает сигнал: «сейчас мы на границе слоёв». Это как табличка “паспортный контроль”: вы же не удивляетесь, что там проверяют документы, а не продают кофе (хотя кофе там тоже иногда продают, но это уже другая архитектура).

Ещё одно правило, которое помогает не утонуть: маппинг должен быть детерминированным. Это значит, что из одного и того же DTO он всегда делает один и тот же Domain. Если вы внезапно начнёте внутри mapper’а делать 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 — поставить текущий”. В этот момент mapper становится мини‑приложением. Если вам нужно правило бизнес‑логики, оно должно жить либо в домене, либо в сервисе, а mapper должен быть пограничником, а не парламентом.

Ошибка №3: try? в маппинге и тихая потеря причин.
try? удобен, когда вам правда не важна причина. Но в маппинге причина часто важна: вы хотите отличать “сервер прислал пустой title” от “сервер прислал странный id”. Если вы превращаете ошибки в nil бездумно, вы потом будете часами гадать, почему у вас “иногда пропадают элементы”.

Ошибка №4: дефолты ставятся “на автомате”, и домен перестаёт быть строгим.
Если на любое отсутствие данных вы отвечаете дефолтом, вы получаете систему, в которой ошибки данных становятся невидимыми. Это похоже на машину, где лампочку “Check Engine” заклеили изолентой: да, больше не раздражает, но двигатель от этого здоровее не становится.

Ошибка №5: в mapper’е появляются случайные значения (Date(), UUID()) и маппинг перестаёт быть детерминированным.
Сегодня оно “просто работает”, а завтра вы захотите написать тест или воспроизвести баг по логам — и не сможете, потому что один и тот же вход уже даёт разные выходы. Mapper должен быть максимально предсказуемым: получил DTO — вернул Domain или ошибку, без сюрпризов.

1
Задача
Swift SELF, 64 уровень, 2 лекция
Недоступна
Очистка профиля
Очистка профиля
1
Задача
Swift SELF, 64 уровень, 2 лекция
Недоступна
Строгий заголовок
Строгий заголовок
1
Задача
Swift SELF, 64 уровень, 2 лекция
Недоступна
Импорт товаров
Импорт товаров
1
Задача
Swift SELF, 64 уровень, 2 лекция
Недоступна
Модерация комментариев
Модерация комментариев
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ