Domain-Driven Design в Swift: как перестать бороться с архитектурой

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему DDD — это не про "ещё один паттерн"

Вы когда-нибудь переписывали проект с нуля только потому, что "бизнес-логика размазалась по всем контроллерам"? Или, может быть, ваш UserManager разросся до 2000 строк, и никто не понимает, что он на самом деле делает? Знакомо?

В мире Swift-разработки мы привыкли к MVC, VIPER или MVVM. Но все эти паттерны решают одну и ту же проблему: как отделить UI от логики. Однако они почти ничего не говорят о том, как организовать саму бизнес-логику. И тут на сцену выходит Domain-Driven Design (DDD) — подход, который ставит в центр не фреймворк, а предметную область вашего приложения.

В этой статье мы разберём, как применить Domain-Driven Design в Swift на реальных примерах. Вы увидите, что DDD — это не абстрактная теория, а мощный инструмент, который делает код читаемым, тестируемым и устойчивым к изменениям.

Что такое DDD простыми словами

Представьте, что вы пишете приложение для интернет-магазина. MVC скажет вам: "Сделай модель Product, контроллер ProductViewController и вьюхи". А DDD спросит: "А что такое 'товар' в вашем бизнесе? Может ли он быть без цены? Что происходит, когда его добавляют в корзину?"

Domain-Driven Design — это способ моделирования программного обеспечения, при котором код максимально точно отражает язык и правила бизнеса. Вместо "технических" сущностей (типа DataManager) вы работаете с доменными объектами: Order, Customer, Invoice.

Основные кирпичики DDD

Чтобы не заблудиться в терминах, запомните три главных понятия:

  • Entity (Сущность) — объект, который имеет идентичность. Два объекта с одинаковыми полями — это разные сущности (как два разных человека с одинаковыми именами).
  • Value Object (Объект-значение) — объект, который определяется только своими атрибутами. Если у двух объектов все поля совпадают — это один и тот же объект (как дата или сумма денег).
  • Aggregate (Агрегат) — группа связанных объектов, которая рассматривается как единое целое. Агрегат имеет корень (root entity), через который происходит доступ ко всем внутренним объектам.

Звучит сухо? Давайте на Swift.

Пишем доменную модель на Swift

Допустим, мы делаем приложение для управления задачами (TODO-лист). Начнём с самого простого — Value Object для статуса задачи.

// Value Object: статус задачи
struct TaskStatus: Equatable {
    let rawValue: String
    
    static let todo = TaskStatus(rawValue: "todo")
    static let inProgress = TaskStatus(rawValue: "inProgress")
    static let done = TaskStatus(rawValue: "done")
    
    // Бизнес-правило: можно перейти только в следующий статус
    func canTransition(to newStatus: TaskStatus) -> Bool {
        switch (self, newStatus) {
        case (.todo, .inProgress), (.inProgress, .done):
            return true
        default:
            return false
        }
    }
}

Обратите внимание: мы не используем enum, потому что в реальном бизнесе статусы могут прилетать с сервера или храниться в БД. TaskStatus — это неизменяемый объект, который сам знает свои правила.

Теперь создадим Entity — саму задачу:

// Entity: задача
final class Task {
    let id: UUID
    var title: String
    var description: String
    private(set) var status: TaskStatus
    let createdAt: Date
    
    init(id: UUID = UUID(), title: String, description: String, status: TaskStatus = .todo) {
        self.id = id
        self.title = title
        self.description = description
        self.status = status
        self.createdAt = Date()
    }
    
    // Доменный метод: изменить статус с проверкой бизнес-правила
    func changeStatus(to newStatus: TaskStatus) throws {
        guard status.canTransition(to: newStatus) else {
            throw DomainError.invalidTransition(current: status, new: newStatus)
        }
        status = newStatus
    }
}

enum DomainError: Error {
    case invalidTransition(current: TaskStatus, new: TaskStatus)
}

Видите? Вся логика изменения статуса живёт внутри домена. Никакой контроллер не может просто так взять и присвоить статус "done" задаче со статусом "done". Бизнес-правило защищено на уровне модели.

Агрегаты: собираем всё вместе

Теперь представьте, что у нас есть проект, в котором живут задачи. Проект — это Агрегат, корнем которого является сам проект. Задачи внутри проекта не могут существовать отдельно.

// Агрегат: Project
final class Project {
    let id: UUID
    var name: String
    private(set) var tasks: [Task] = []
    
    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
    
    // Добавление задачи через корень агрегата
    func addTask(title: String, description: String) -> Task {
        let task = Task(title: title, description: description)
        tasks.append(task)
        return task
    }
    
    // Удаление задачи
    func removeTask(_ task: Task) {
        tasks.removeAll { $0.id == task.id }
    }
}

Ключевой принцип: внешние объекты никогда не обращаются напрямую к tasks. Только через методы addTask и removeTask. Это гарантирует целостность агрегата.

Репозитории: как достать данные

В DDD репозиторий — это интерфейс для получения доменных объектов. Он скрывает детали хранения (CoreData, Realm, API). В Swift это отлично ложится на протоколы.

protocol TaskRepository {
    func getAllTasks() async throws -> [Task]
    func getTask(by id: UUID) async throws -> Task?
    func save(_ task: Task) async throws
    func delete(_ task: Task) async throws
}

final class CoreDataTaskRepository: TaskRepository {
    func getAllTasks() async throws -> [Task] {
        // Здесь код для CoreData
        return []
    }
    
    func getTask(by id: UUID) async throws -> Task? {
        // Запрос к CoreData
        return nil
    }
    
    func save(_ task: Task) async throws {
        // Сохранение
    }
    
    func delete(_ task: Task) async throws {
        // Удаление
    }
}

Теперь, если вы захотите перейти с CoreData на SwiftData или Firebase, вам нужно будет реализовать только новый класс, соответствующий протоколу TaskRepository. Вся остальная логика останется нетронутой.

Доменные сервисы: когда логика не помещается в одну сущность

Иногда бизнес-правила затрагивают несколько агрегатов. Например, "перенести задачу из одного проекта в другой". Это не метод Task и не метод Project — это отдельный доменный сервис.


                
                

Блоги

Рекомендаций по книгам