Шестиугольная архитектура в Swift: чистая логика без зависимостей

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

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

Начать курс

Когда MVVM и MVC перестают спасать, а тесты превращаются в ад

Вы когда-нибудь писали приложение на Swift, где через полгода добавление одной фичи ломало три других, а тесты приходилось переписывать целиком, потому что база данных поменялась с Core Data на Realm? Если да — вы не одиноки. Классические слоистые архитектуры (MVC, MVVM) отлично работают на старте, но превращаются в «кашу» по мере роста проекта.

Я покажу вам Hexagonal архитектуру (она же Ports & Adapters) — подход, который пришёл из мира Java и DDD, но прекрасно ложится на Swift. Мы разберём её на реальных примерах, без абстрактных «User» и «Manager». Вы увидите, как отделить бизнес-логику от базы данных, UI и сети так, чтобы их можно было менять, не трогая ядро приложения.

Это не очередная теория — это практика, которую вы сможете применить в своём проекте уже сегодня.

Что такое Hexagonal архитектура? Простыми словами

Представьте себе шестигранник (отсюда и название). В центре — ваша чистая бизнес-логика (домен). По краям — порты (интерфейсы), через которые внешний мир общается с ядром. Адаптеры — это конкретные реализации: база данных, API, UI, файловая система.

Главное правило: ядро ничего не знает о внешнем мире. Ни о UIKit, ни о URLSession, ни о Core Data. Оно знает только про свои интерфейсы (порты).

Пример из жизни: представьте интернет-магазин. Ядро знает, что есть «Корзина» и «Оформить заказ». Ему всё равно, откуда приходят данные — из веб-сокета, из локального JSON или с клавиатуры. Адаптеры подключаются к портам и переводят внешние сигналы в доменные команды.

Почему это стоит использовать в Swift-проектах?

  • Тестируемость — вы тестируете домен без реальной базы данных и сети. Просто подменяете адаптеры моками.
  • Гибкость — захотели перейти с Firebase на свой сервер? Меняете один адаптер, а не половину приложения.
  • Читаемость — бизнес-логика не размазана по ViewController'ам. Она лежит в одном месте.
  • Независимость от фреймворков — ваше ядро не импортирует UIKit, Combine или Alamofire.

Структура проекта на Hexagonal архитектуре в Swift

Давайте сразу перейдём к коду. Предположим, мы пишем приложение для управления задачами (Todolist). Вот как будет выглядеть структура папок:

App/
├── Domain/
│   ├── Entity/
│   │   └── Task.swift
│   ├── UseCase/
│   │   └── CreateTaskUseCase.swift
│   └── Port/
│       ├── TaskRepository.swift
│       └── TaskOutputPort.swift
├── Adapter/
│   ├── Persistence/
│   │   └── CoreDataTaskRepository.swift
│   ├── Network/
│   │   └── APITaskRepository.swift
│   └── Presentation/
│       └── TaskListViewModel.swift
└── Main/
    └── AppDelegate.swift

Обратите внимание: Domain не импортирует ничего, кроме Foundation. Adapter может импортировать CoreData, UIKit, Alamofire — всё, что угодно.

Шаг 1: Доменные сущности и порты

Начнём с самого важного — доменной модели. Это struct или enum, которые описывают бизнес-сущность. Никаких NSManagedObject, никаких Codable.

// Domain/Entity/Task.swift
struct Task {
    let id: UUID
    var title: String
    var isCompleted: Bool
    let createdAt: Date
}

Теперь определим порт для репозитория (выходной порт). Это протокол, который описывает, как мы сохраняем и загружаем задачи.

// Domain/Port/TaskRepository.swift
protocol TaskRepository {
    func fetchAll() async throws -> [Task]
    func save(task: Task) async throws
    func delete(taskID: UUID) async throws
}

И входной порт — то, через что внешний мир (UI, API) взаимодействует с ядром. Обычно это UseCase.

// Domain/UseCase/CreateTaskUseCase.swift
final class CreateTaskUseCase {
    private let repository: TaskRepository
    
    init(repository: TaskRepository) {
        self.repository = repository
    }
    
    func execute(title: String) async throws -> Task {
        let task = Task(
            id: UUID(),
            title: title,
            isCompleted: false,
            createdAt: Date()
        )
        try await repository.save(task: task)
        return task
    }
}

Видите? CreateTaskUseCase не знает, откуда берётся TaskRepository — из Core Data, Realm или мока. Это и есть суть Hexagonal архитектуры.

Шаг 2: Адаптеры — конкретные реализации

Теперь напишем адаптер для Core Data. Он реализует протокол TaskRepository и знает про управляемый контекст.

// Adapter/Persistence/CoreDataTaskRepository.swift
import CoreData

final class CoreDataTaskRepository: TaskRepository {
    private let context: NSManagedObjectContext
    
    init(context: NSManagedObjectContext) {
        self.context = context
    }
    
    func fetchAll() async throws -> [Task] {
        let request = TaskEntity.fetchRequest()
        let entities = try context.fetch(request)
        return entities.map { entity in
            Task(
                id: entity.id ?? UUID(),
                title: entity.title ?? "",
                isCompleted: entity.isCompleted,
                createdAt: entity.createdAt ?? Date()
            )
        }
    }
    
    func save(task: Task) async throws {
        let entity = TaskEntity(context: context)
        entity.id = task.id
        entity.title = task.title
        entity.isCompleted = task.isCompleted
        entity.createdAt = task.createdAt
        try context.save()
    }
    
    func delete(taskID: UUID) async throws {
        let request = TaskEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
        let entities = try context.fetch(request)
        entities.forEach { context.delete($0) }
        try context.save()
    }
}

А если мы хотим тестировать — пишем мок-адаптер:

// Adapter/Test/MockTaskRepository.swift
final class MockTaskRepository: TaskRepository {
    var tasks: [Task] = []
    
    func fetchAll() async throws -> [Task] {
        return tasks
    }
    
    func save(task: Task) async throws {
        tasks.append(task)
    }
    
    func delete(taskID: UUID) async throws {
        tasks.removeAll { $0.id == taskID }
    }
}

Шаг 3: Сборка — Dependency Injection без фреймворков

Теперь самое интересное — как всё это соединить. Никаких Service Locator или Resolver. Просто фабрика в AppDelegate или сцене.

// Main/AppDelegate.swift
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        // Создаём адаптеры
        let persistentContainer = NSPersistentContainer(name: "Model")                

Блоги

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