Настройка архитектуры The Composable Architecture (TCA) для iOS-приложения
TCA от Point-Free — не просто ещё один способ расположить папки в Xcode. Это строгий однонаправленный поток данных, где состояние всего приложения изменяется только через Reducer, а каждое изменение тестируется детерминированно. Если вы работаете с SwiftUI, сложной навигацией и большой командой — TCA даёт инструментарий, которого нет у MVVM.
Основные концепции в коде
Store, State, Action, Reducer, Effect — пять китов TCA.
State — структура, описывающая всё, что нужно экрану. Action — enum с ассоциированными значениями, описывающий всё, что может произойти. Reducer — чистая функция (State, Action) -> Effect<Action>. Effect — обёртка над асинхронной работой (сеть, таймеры, MotionManager).
@Reducer
struct ProfileFeature {
@ObservableState
struct State: Equatable {
var user: UserProfile?
var isLoading = false
var errorMessage: String?
}
enum Action {
case loadProfile(id: String)
case profileLoaded(Result<UserProfile, Error>)
case editButtonTapped
}
@Dependency(\.userClient) var userClient
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let .loadProfile(id):
state.isLoading = true
return .run { send in
await send(.profileLoaded(
Result { try await userClient.fetch(id) }
))
}
case let .profileLoaded(.success(user)):
state.isLoading = false
state.user = user
return .none
case let .profileLoaded(.failure(error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
case .editButtonTapped:
return .none
}
}
}
}
View не содержит логики: store.send(.loadProfile(id: userId)) и store.user — всё взаимодействие через Store.
Composition: вот где TCA реально блестит
Главная сила TCA — scope и composability. Большое приложение собирается из маленьких Reducer-ов:
@Reducer
struct AppFeature {
struct State {
var profile = ProfileFeature.State()
var feed = FeedFeature.State()
}
enum Action {
case profile(ProfileFeature.Action)
case feed(FeedFeature.Action)
}
var body: some Reducer<State, Action> {
Scope(state: \.profile, action: \.profile) { ProfileFeature() }
Scope(state: \.feed, action: \.feed) { FeedFeature() }
}
}
Каждый модуль разрабатывается независимо. ProfileFeature ничего не знает о FeedFeature. Это позволяет разбить команду на изолированные потоки разработки.
Dependency system — замена синглтонам
TCA поставляется с DependencyValues — механизмом инъекции зависимостей, который заменяет URLSession.shared и UserDefaults.standard в продакшене на тестовые заглушки. Это не Service Locator: зависимости объявляются явно через @Dependency(\.userClient).
extension DependencyValues {
var userClient: UserClient {
get { self[UserClientKey.self] }
set { self[UserClientKey.self] = newValue }
}
}
В тестах: withDependencies { $0.userClient = .mock } { ... }. Никакого протокола-заглушки, никаких setUp/tearDown с глобальным состоянием.
Тесты: детерминированный TestStore
func test_loadProfile_success() async {
let store = TestStore(initialState: ProfileFeature.State()) {
ProfileFeature()
} withDependencies: {
$0.userClient.fetch = { _ in .stub(id: "42") }
}
await store.send(.loadProfile(id: "42")) {
$0.isLoading = true
}
await store.receive(.profileLoaded(.success(.stub(id: "42")))) {
$0.isLoading = false
$0.user = .stub(id: "42")
}
}
TestStore требует явно описать каждое изменение состояния. Если что-то изменилось, но не описано — тест падает. Это дорогостоящее тестирование писать, но оно полностью исключает регрессии по состоянию.
Навигация в TCA: NavigationStack и tree-based
С TCA 1.x появилась поддержка NavigationStack через StackState/StackAction. Альтернатива — PresentationState/PresentationAction для sheets, alerts, popovers. Все навигационные состояния — часть State, сериализуемы и тестируемы:
@Reducer
struct AppFeature {
struct State {
var path = StackState<Path.State>()
}
@Reducer enum Path {
case profile(ProfileFeature)
case settings(SettingsFeature)
}
}
Deep link открывается через store.send(.setPath([.profile(...), .settings(...)])). Тест проверяет состояние навигационного стека без UI.
Когда TCA не нужна
Небольшое приложение (5–10 экранов, один разработчик) — TCA добавит бойлерплейт без пропорциональной выгоды. MVVM + Combine или даже @StateObject с сервисами справятся дешевле.
TCA окупается при: команде от 3 человек, сложной навигации с deep links, требовании 80%+ тестового покрытия, фичах с реальным параллелизмом (sync/async эффекты, таймеры, WebSocket).
Что делаем при настройке
Добавляем TCA через Swift Package Manager (swift-composable-architecture актуальная версия). Настраиваем первый Reducer — как образец для команды с покрытием через TestStore. Переносим существующую логику из ViewModel/ViewController в TCA-модули поэкранно.
Обучение команды: разбираем на реальном коде проекта, не на абстрактных примерах.
Сроки
Настройка TCA с нуля + первые 3 экрана с тестами: 5–8 дней. Миграция существующего MVVM-проекта на TCA (10–20 экранов): 3–6 недель. Стоимость — после анализа объёма и текущей архитектуры.







