Когда 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")