add agent skills and opencode config

This commit is contained in:
Millian Lamiaux
2026-05-10 20:09:13 +01:00
parent 349a96379e
commit 03f660958f
91 changed files with 15526 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
---
name: swift-architecture-skill
description: Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
license: MIT
---
# Swift Architecture Skill
## Overview
Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the users task.
## Workflow
### Step 1: Analyze the Request Context
Before selecting an architecture, capture:
- task type (new feature, refactor, PR review, debugging)
- UI stack (SwiftUI, UIKit, or mixed)
- scope (single screen, multi-screen, app-wide)
- existing conventions to preserve
### Step 2: Select the Architecture
If the user explicitly names an architecture, treat it as the initial candidate and run a fit check before committing:
- validate against UI stack fit (SwiftUI/UIKit/mixed), state complexity, effect orchestration needs, team familiarity, and existing codebase conventions
- if it fits, proceed with the requested architecture
- if it mismatches key constraints, explicitly explain the mismatch and recommend the closest-fit alternative from `references/selection-guide.md`
- if the user still insists on a mismatched architecture, proceed with a risk-mitigated plan and state the risks up front
When no architecture is named, load `references/selection-guide.md` and infer the best fit from stated constraints (state complexity, team familiarity, testing goals, effect orchestration needs, and framework preferences). Explain the recommendation briefly.
Architecture reference mapping:
- MVVM → `references/mvvm.md`
- MVI → `references/mvi.md`
- TCA → `references/tca.md`
- Clean Architecture → `references/clean-architecture.md`
- VIPER → `references/viper.md`
- Reactive → `references/reactive.md`
- MVP → `references/mvp.md`
- Coordinator → `references/coordinator.md`
### Step 3: Analyze Existing Codebase (When Applicable)
When code already exists:
- detect current architecture and DI style
- note concurrency model (async/await, Combine, GCD, mixed)
- align recommendations to local conventions
### Step 4: Produce Concrete Deliverables
Read the selected architecture reference and convert its guidance into deliverables tailored to the user's request:
- **File and module structure**: directory layout with file names specific to the feature
- **State and dependency boundaries**: concrete types, protocols, and injection points
- **Async strategy**: cancellation, actor isolation, and error paths
- **Testing strategy**: what to test, how to stub dependencies, and example test structure
- **Migration path** (for refactors): incremental steps to move from current to target architecture
- **UI stack adaptation**: where SwiftUI and UIKit guidance should differ for the chosen architecture
### Step 5: Validate with Checklist
End with the architecture-specific PR review checklist from the reference file, adapted to the user's feature.
## Output Requirements
- Keep recommendations scoped to the requested feature or review task.
- Prefer protocol-based dependency injection and explicit state modeling.
- Flag anti-patterns found in existing code and provide direct fixes.
- Include cancellation and error handling in all async flows.
- For explicit architecture requests, include a short fit result (`fit` or `mismatch`) with 1-2 reasons.
- For mismatch cases, include one closest-fit alternative and why it better matches the stated constraints.
- When writing code, include only the patterns relevant to the task — do not dump entire playbooks.
- Treat reference snippets as illustrative by default; add full compile scaffolding only if the user asks for runnable code.
- Ask only minimum blocking questions; otherwise proceed with explicit assumptions stated up front.
- When reviewing PRs, use the architecture-specific checklist and call out specific violations with line-level fixes.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Swift Architecture Skill"
short_description: "Architecture guidance for Swift, SwiftUI, and UIKit iOS projects"
default_prompt: "Use this skill to select and apply the right architecture for Swift, SwiftUI, and UIKit features, then provide concrete implementation guidance and review checks."

View File

@@ -0,0 +1,322 @@
# Clean Architecture Playbook (Swift + SwiftUI/UIKit)
Use this reference when a Swift codebase needs strict layer boundaries and use-case-driven business logic.
## Core Dependency Rule
Dependencies point inward:
```text
Frameworks / UI
->
Interface Adapters
->
Use Cases
->
Entities (Domain)
```
Rules:
- inner layers must not import or depend on outer layers
- domain remains pure Swift
- frameworks are implementation details and replaceable
## Canonical Layer Layout
```text
Domain/
Entities/
UseCases/
Data/
Repositories/
API/
Persistence/
Presentation/
Features/
App/
```
Guidance:
- keep entities and use-case protocols in `Domain`
- keep repository implementations and external adapters in `Data`
- keep views/view models/controllers in `Presentation`
- keep DI composition root and app bootstrap in `App`
## Entities
Entities model core business concepts and rules.
```swift
struct User: Equatable {
let id: UUID
let name: String
}
```
Rules:
- no SwiftUI/UIKit imports
- no persistence or network behavior
- avoid framework-specific types unless unavoidable
## Use Cases
Use cases orchestrate business actions through abstractions.
```swift
protocol LoadUserUseCase {
func execute(id: UUID) async throws -> User
}
final class LoadUser: LoadUserUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute(id: UUID) async throws -> User {
try await repository.fetch(id: id)
}
}
```
Rules:
- one business responsibility per use case
- no UI details
- no direct framework usage unless abstracted
## Repository Boundary
Define repository protocols in `Domain`; implement them in `Data`.
```swift
protocol UserRepository {
func fetch(id: UUID) async throws -> User
}
```
Data-layer implementations can coordinate:
- API clients
- local persistence
- mapping DTOs to domain entities
## Dependency Injection Pattern
Compose live dependencies in the app or feature assembly layer.
```swift
enum UserFeatureAssembly {
static func makeLoadUserUseCase() -> LoadUserUseCase {
let repository = LiveUserRepository(api: .live)
return LoadUser(repository: repository)
}
}
```
Rules:
- inject protocols into use cases and presentation
- avoid global singletons as hidden dependencies
## DTO to Domain Mapping
Map external models to domain entities at the data-layer boundary, in mappers or repository implementations.
```swift
struct UserDTO: Decodable {
let id: String
let full_name: String
let created_at: String
}
enum UserMapper {
static func toDomain(_ dto: UserDTO) throws -> User {
guard let id = UUID(uuidString: dto.id) else {
throw MappingError.invalidID(dto.id)
}
return User(id: id, name: dto.full_name)
}
}
enum MappingError: Error {
case invalidID(String)
}
final class LiveUserRepository: UserRepository {
private let api: APIClient
init(api: APIClient) {
self.api = api
}
func fetch(id: UUID) async throws -> User {
let dto = try await api.fetchUser(id: id)
return try UserMapper.toDomain(dto)
}
}
```
Rules:
- never expose DTOs beyond the data layer
- test mappers independently for edge cases and invalid input
- keep mapping pure and side-effect-free
## Concurrency and Cancellation
Use structured concurrency in use cases and let cancellation propagate through async calls.
```swift
final class LoadUserProfile: LoadUserProfileUseCase {
private let userRepo: UserRepository
private let postsRepo: PostsRepository
init(userRepo: UserRepository, postsRepo: PostsRepository) {
self.userRepo = userRepo
self.postsRepo = postsRepo
}
func execute(id: UUID) async throws -> UserProfile {
async let user = userRepo.fetch(id: id)
async let posts = postsRepo.fetchRecent(userID: id)
return try await UserProfile(user: user, posts: posts)
}
}
```
Rules:
- prefer `async let` for concurrent independent fetches
- cancellation propagates automatically through `try await`
- use `Task.checkCancellation()` before expensive work if needed
- in presentation, cancel tasks on view disappearance or new request
## Presentation Boundary
Presentation depends on use-case abstractions, not data implementations.
Expected flow:
- View triggers intent/event
- Presentation layer calls `UseCase`
- UseCase returns domain entities
- Presentation maps entities to view state
SwiftUI adaptation:
- use `@Observable`/`ObservableObject` ViewModels that expose view state
- trigger use cases from intent methods on the ViewModel
- keep SwiftUI views declarative and free of use-case/repository calls
UIKit adaptation:
- use Presenter/ViewModel objects owned by view controllers
- convert delegate/target-action events into presenter intents
- keep controllers responsible for rendering only; business coordination stays in presenter/use case layers
## Anti-Patterns and Fixes
1. God use case:
- Smell: single 500+ line use case handling many responsibilities.
- Fix: split by business capability and compose use cases.
2. Presentation imports data layer:
- Smell: feature view model directly uses `LiveRepository` or API client.
- Fix: depend on use-case protocol only.
3. Domain depends on frameworks:
- Smell: domain entities use UI/network/persistence frameworks.
- Fix: keep domain pure and move adapters outward.
4. Repository leaks transport types:
- Smell: presentation receives DTO/network models.
- Fix: map external models to domain entities in data layer.
5. Testing through real infrastructure:
- Smell: unit tests require network/db.
- Fix: test use cases with mocked/stub repositories.
## Testing Strategy
Prioritize:
- use-case unit tests with repository stubs
- mapper tests (DTO <-> domain) in data layer
- presentation tests with mocked use cases
Rules:
- avoid network in unit tests
- assert business behavior at use-case boundary
- keep async tests deterministic using controlled stubs
- test cancellation propagation for long-running use cases
```swift
struct StubUserRepository: UserRepository {
var result: Result<User, Error>
func fetch(id: UUID) async throws -> User {
try result.get()
}
}
@MainActor
final class LoadUserTests: XCTestCase {
func test_execute_returnsUser() async throws {
let expected = User(id: UUID(), name: "Alice")
let sut = LoadUser(repository: StubUserRepository(result: .success(expected)))
let user = try await sut.execute(id: expected.id)
XCTAssertEqual(user, expected)
}
func test_execute_propagatesFailure() async {
let sut = LoadUser(repository: StubUserRepository(result: .failure(TestError.notFound)))
do {
_ = try await sut.execute(id: UUID())
XCTFail("Expected error")
} catch {
XCTAssertTrue(error is TestError)
}
}
func test_execute_cancellationPropagates() async {
let sut = LoadUser(repository: BlockingUserRepository())
// Deterministic because this test class is @MainActor:
// Task { ... } inherits main-actor isolation and does not start executing
// until the main actor yields at await task.value, so cancellation is
// observed immediately. Without @MainActor this pattern is racy.
let task = Task { try await sut.execute(id: UUID()) }
task.cancel()
do {
_ = try await task.value
XCTFail("Expected cancellation")
} catch is CancellationError {
// expected
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
private actor BlockingUserRepository: UserRepository {
func fetch(id: UUID) async throws -> User {
try await Task.sleep(for: .seconds(60))
return User(id: id, name: "")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer Clean Architecture
Prefer when:
- app/domain complexity is medium to large
- multiple teams need stable boundaries
- long-term maintainability and replaceable infrastructure matter
Prefer lighter layering when:
- app is small and short-lived
- strict layering overhead is higher than expected benefit
## PR Review Checklist
- Dependency direction points inward only.
- Domain layer is framework-independent.
- Use cases encapsulate business rules and stay focused.
- Presentation does not import data implementations.
- Repository abstractions live at domain boundary.
- Tests isolate use cases from infrastructure.

View File

@@ -0,0 +1,466 @@
# Coordinator Playbook (Swift + SwiftUI/UIKit)
Use this reference when navigation logic needs to be decoupled from individual screens, enabling reusable flows, deep linking, and testable routing without view controllers owning their own transitions.
## Core Concept
A Coordinator owns one navigation flow. It creates and connects screens, passes dependencies, and decides what happens next when a user action triggers a transition.
```text
AppCoordinator
-> AuthCoordinator (owns login/signup flow)
-> MainCoordinator (owns tab/home flow)
-> ProfileCoordinator (owns profile flow)
```
Rules:
- each coordinator owns one flow (a screen, a sub-flow, or a full section)
- screens emit navigation events; coordinators decide what to do with them
- screens do not reference coordinators or push/present directly
- parent coordinators launch child coordinators for nested flows
## Feature Structure
```text
App/
AppCoordinator.swift
Coordinators/
AuthCoordinator.swift
MainCoordinator.swift
ProfileCoordinator.swift
Features/
Auth/
LoginViewModel.swift
LoginView.swift
Profile/
ProfileViewModel.swift
ProfileView.swift
Navigation/
Coordinator.swift (protocol)
NavigationRouter.swift (UIKit helper)
```
## Coordinator Protocol
Define a minimal base contract.
```swift
@MainActor
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
func start()
}
extension Coordinator {
func addChild(_ coordinator: Coordinator) {
childCoordinators.append(coordinator)
coordinator.start()
}
func removeChild(_ coordinator: Coordinator) {
childCoordinators.removeAll { $0 === coordinator }
}
}
```
Rules:
- retain child coordinators so they are not deallocated mid-flow
- remove child coordinators when the flow they own completes
- `start()` is the single entry point that kicks off the flow
## UIKit Coordinator
For UIKit, wrap a `UINavigationController` in a thin router.
```swift
@MainActor
final class NavigationRouter {
let navigationController: UINavigationController
init(navigationController: UINavigationController = UINavigationController()) {
self.navigationController = navigationController
}
func push(_ viewController: UIViewController, animated: Bool = true) {
navigationController.pushViewController(viewController, animated: animated)
}
func present(_ viewController: UIViewController, animated: Bool = true) {
navigationController.present(viewController, animated: animated)
}
func pop(animated: Bool = true) {
navigationController.popViewController(animated: animated)
}
func popToRoot(animated: Bool = true) {
navigationController.popToRootViewController(animated: animated)
}
}
```
Profile flow coordinator example:
```swift
@MainActor
final class ProfileCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let router: NavigationRouter
private let userRepository: UserRepository
init(router: NavigationRouter, userRepository: UserRepository) {
self.router = router
self.userRepository = userRepository
}
func start() {
let viewModel = ProfileViewModel(
repository: userRepository,
onEditTapped: { [weak self] in self?.showEditProfile() },
onLogoutTapped: { [weak self] in self?.finish() }
)
let viewController = ProfileViewController(viewModel: viewModel)
router.push(viewController)
}
private func showEditProfile() {
let editCoordinator = EditProfileCoordinator(
router: router,
userRepository: userRepository,
onComplete: { [weak self] in self?.removeChild($0) }
)
addChild(editCoordinator)
}
private func finish() {
// Notify parent this flow is done.
}
}
```
## SwiftUI Coordinator
For SwiftUI, model navigation state as a value type and bind it to `NavigationStack`.
```swift
@MainActor
@Observable
final class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var path: [AppDestination] = []
var sheet: AppSheet?
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func start() {
// Nothing to push root is set at view layer.
}
func showProfile(userID: UUID) {
path.append(.profile(userID))
}
func showSettings() {
sheet = .settings
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func dismissSheet() {
sheet = nil
}
}
enum AppDestination: Hashable {
case profile(UUID)
case editProfile(UUID)
}
enum AppSheet: Identifiable {
case settings
var id: String { "\(self)" }
}
```
Root view binds coordinator state to `NavigationStack`:
```swift
struct AppRootView: View {
@State private var coordinator: AppCoordinator
init(coordinator: AppCoordinator) {
self._coordinator = State(initialValue: coordinator)
}
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView(
onProfileTapped: { id in coordinator.showProfile(userID: id) },
onSettingsTapped: { coordinator.showSettings() }
)
.navigationDestination(for: AppDestination.self) { destination in
switch destination {
case .profile(let id):
ProfileView(viewModel: makeProfileViewModel(userID: id))
case .editProfile(let id):
EditProfileView(userID: id)
}
}
}
.sheet(item: $coordinator.sheet) { sheet in
switch sheet {
case .settings:
SettingsView(onDismiss: { coordinator.dismissSheet() })
}
}
}
private func makeProfileViewModel(userID: UUID) -> ProfileViewModel {
ProfileViewModel(
userID: userID,
repository: coordinator.userRepository,
onEditTapped: { coordinator.path.append(.editProfile(userID)) }
)
}
}
```
Rules:
- model destinations as a `Hashable` enum so `NavigationStack` can drive them
- model sheets as an `Identifiable` enum to bind `sheet(item:)`
- mutate coordinator state on the main actor
- avoid deep conditional nesting in the `navigationDestination` closure — prefer `switch`
## Child Coordinator Pattern
Parent coordinators own child coordinators for nested flows.
```swift
@MainActor
final class MainCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
private let router: NavigationRouter
private let userRepository: UserRepository
init(router: NavigationRouter, userRepository: UserRepository) {
self.router = router
self.userRepository = userRepository
}
func start() {
showHome()
}
func showHome() {
let viewModel = HomeViewModel(
onProfileTapped: { [weak self] id in self?.showProfile(userID: id) }
)
let viewController = HomeViewController(viewModel: viewModel)
router.push(viewController)
}
private func showProfile(userID: UUID) {
let profileRouter = NavigationRouter(
navigationController: router.navigationController
)
let coordinator = ProfileCoordinator(
router: profileRouter,
userRepository: userRepository
)
addChild(coordinator)
}
}
```
## Deep Linking
Handle deep links by parsing a URL into a destination and routing directly to it.
Push destinations update `path`; sheet destinations set `sheet`.
```swift
@MainActor
final class DeepLinkHandler {
private let coordinator: AppCoordinator
init(coordinator: AppCoordinator) {
self.coordinator = coordinator
}
func handle(url: URL) {
guard url.scheme == "myapp" else { return }
switch url.host {
case "profile":
guard
let idString = url.pathComponents.dropFirst().first,
let id = UUID(uuidString: idString)
else { return }
coordinator.path = [.profile(id)]
case "settings":
coordinator.sheet = .settings
default:
break
}
}
}
```
## Anti-Patterns and Fixes
1. View controller pushes its own next screen:
- Smell: `ProfileViewController` calls `navigationController?.pushViewController(SettingsViewController(), animated: true)` directly.
- Fix: emit a closure or delegate event; let the Coordinator perform the push.
2. Coordinator retained only by a local variable:
- Smell: parent loses reference to child coordinator; it deallocates mid-flow.
- Fix: add child to `childCoordinators` before calling `start()`.
3. Navigation logic spread across ViewModels:
- Smell: ViewModel holds a reference to `AppCoordinator` and calls `coordinator.showSettings()` directly.
- Fix: inject navigation closures (`onSettingsTapped: () -> Void`) so the ViewModel stays decoupled from the coordinator type.
4. Deep linking bypasses coordinator:
- Smell: `AppDelegate` calls `navigationController.pushViewController(...)` directly on deep link receipt.
- Fix: route all deep links through `DeepLinkHandler``AppCoordinator.handle(url:)`.
5. Coordinator mixing business logic:
- Smell: Coordinator fetches data or applies business rules before routing.
- Fix: keep Coordinator responsible only for navigation; delegate data work to ViewModels/Repositories.
## Testing Strategy
Test Coordinators by verifying navigation state changes for success paths (expected destinations appended), failure paths (unknown inputs handled without crashing), and cancellation-safe pop operations.
Use stub repositories and direct coordinator state inspection to keep tests deterministic.
Avoid sleeps; prefer synchronous state mutations and direct property assertions.
```swift
@MainActor
final class SpyNavigationRouter: NavigationRouter {
var pushedViewControllers: [UIViewController] = []
var presentedViewControllers: [UIViewController] = []
override func push(_ viewController: UIViewController, animated: Bool = true) {
pushedViewControllers.append(viewController)
}
override func present(_ viewController: UIViewController, animated: Bool = true) {
presentedViewControllers.append(viewController)
}
}
@MainActor
final class ProfileCoordinatorTests: XCTestCase {
func test_start_pushesProfileViewController() {
let router = SpyNavigationRouter()
let coordinator = ProfileCoordinator(
router: router,
userRepository: StubUserRepository()
)
coordinator.start()
XCTAssertEqual(router.pushedViewControllers.count, 1)
XCTAssertTrue(router.pushedViewControllers.first is ProfileViewController)
}
func test_showEditProfile_addsChildCoordinator() {
let router = SpyNavigationRouter()
let coordinator = ProfileCoordinator(
router: router,
userRepository: StubUserRepository()
)
coordinator.start()
coordinator.showEditProfileForTesting()
XCTAssertEqual(coordinator.childCoordinators.count, 1)
}
}
@MainActor
final class AppCoordinatorTests: XCTestCase {
func test_showProfile_success_appendsDestination() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
let id = UUID()
coordinator.showProfile(userID: id)
XCTAssertEqual(coordinator.path, [.profile(id)])
}
func test_pop_removesLastDestination() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
coordinator.path = [.profile(UUID()), .editProfile(UUID())]
coordinator.pop()
XCTAssertEqual(coordinator.path.count, 1)
}
func test_dismissSheet_clearsSheet() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
coordinator.sheet = .settings
coordinator.dismissSheet()
XCTAssertNil(coordinator.sheet)
}
func test_deepLink_failure_doesNotCrashOnUnknownScheme() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
let handler = DeepLinkHandler(coordinator: coordinator)
let unknownURL = URL(string: "https://example.com/profile/123")!
handler.handle(url: unknownURL)
XCTAssertTrue(coordinator.path.isEmpty)
}
func test_pop_cancellation_onEmptyPath_doesNotCrash() {
let coordinator = AppCoordinator(userRepository: StubUserRepository())
XCTAssertTrue(coordinator.path.isEmpty)
coordinator.pop()
XCTAssertTrue(coordinator.path.isEmpty)
}
}
struct StubUserRepository: UserRepository {
func fetchCurrentUser() async throws -> User {
User(id: UUID(), name: "Stub", isPremium: false, joinDate: .now)
}
}
```
Note: `showEditProfileForTesting()` exposes the private routing action for test access — annotate with `#if DEBUG` or use `@testable import` and `internal` access level to keep production code clean.
## When to Prefer Coordinator
Prefer Coordinator when:
- navigation logic is complex (conditional flows, deep linking, multi-step wizards)
- multiple screens need to be reused across different flows
- you want to test routing logic without instantiating full screens
- ViewModels and View Controllers should have zero navigation coupling
Pair with MVVM by injecting navigation closures into ViewModels; pair with MVP by having the Presenter call a Router protocol backed by a Coordinator.
The Coordinator pattern is not an architecture on its own — it is a navigation layer that complements presentation patterns. Prefer it when `UINavigationController` push/present calls scattered across view controllers make flows hard to follow or test.
## PR Review Checklist
- Each coordinator owns one clearly scoped flow.
- Child coordinators are retained in `childCoordinators` before `start()` is called.
- Child coordinators are removed when their flow completes.
- ViewModels and View Controllers receive navigation closures, not coordinator references.
- Navigation state (SwiftUI path/sheet) is modeled as value types.
- Deep link handling routes through the coordinator, not directly to view controllers.
- Tests verify routing state changes without relying on UIKit presentation timing.

View File

@@ -0,0 +1,570 @@
# MVI Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow and deterministic state transitions.
## Mental Model
```text
Intent -> Reducer -> New State -> View
-> Effect -> Action -> Reducer
```
Core rules:
- Keep one source of truth: `State`.
- Keep reducer logic deterministic.
- Isolate side effects in `Effect`.
- Feed effect output back as `Action`.
## Core Types
### State
- Use value types (`struct`) only.
- Keep state equatable/serializable where practical.
- Store canonical state, not redundant derived values.
```swift
enum Loadable<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
struct CounterState: Equatable {
var load: Loadable<Int> = .idle
var count: Int {
guard case .loaded(let value) = load else { return 0 }
return value
}
}
```
### Intent
- Represent user-driven input only.
- Do not use intents for network responses.
```swift
enum CounterIntent {
case incrementTapped
case decrementTapped
case resetTapped
}
```
### Action
- Represent internal events and effect results.
- Reducer handles actions to complete async loops.
```swift
enum CounterAction {
case incrementResponse(Result<Int, Error>)
case decrementResponse(Result<Int, Error>)
case resetResponse(Result<Int, Error>)
}
```
Action reducer for completing async transitions:
```swift
func reduce(state: inout CounterState, action: CounterAction) {
switch action {
case .incrementResponse(.success(let value)):
state.load = .loaded(value)
case .incrementResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
case .decrementResponse(.success(let value)):
state.load = .loaded(value)
case .decrementResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
case .resetResponse(.success(let value)):
state.load = .loaded(value)
case .resetResponse(.failure(let error)):
state.load = .failed(error.localizedDescription)
}
}
```
### Effect
- Encapsulate async side effects.
- Keep effect execution in the store.
```swift
enum Effect<Action> {
case none
case run(() async throws -> Action)
case cancellable(id: AnyHashable, () async throws -> Action)
}
```
## Reducer Pattern
- Reducer over `Intent`: mutate state for immediate transitions and optionally return effect.
- Reducer over `Action`: finish transition from effect output.
- Avoid direct side effects inside reducer branches.
```swift
protocol CounterServicing {
func increment() async throws -> Int
func decrement() async throws -> Int
func reset() async throws -> Int
}
func reduce(
state: inout CounterState,
intent: CounterIntent,
service: CounterServicing
) -> Effect<CounterAction>? {
switch intent {
case .incrementTapped:
state.load = .loading
return .run {
do {
let value = try await service.increment()
return .incrementResponse(.success(value))
} catch {
return .incrementResponse(.failure(error))
}
}
case .decrementTapped:
state.load = .loading
return .run {
do {
let value = try await service.decrement()
return .decrementResponse(.success(value))
} catch {
return .decrementResponse(.failure(error))
}
}
case .resetTapped:
state.load = .loading
return .run {
do {
let value = try await service.reset()
return .resetResponse(.success(value))
} catch {
return .resetResponse(.failure(error))
}
}
}
}
```
This signature is a pragmatic shortcut: passing `service` into `reduce` keeps call sites simple, but the reducer is environment-coupled. If you want stricter MVI purity, make `reduce` return effect descriptors and run them outside the reducer.
```swift
enum CounterEffect {
case increment
case decrement
case reset
}
func reduce(state: inout CounterState, intent: CounterIntent) -> CounterEffect? {
switch intent {
case .incrementTapped:
state.load = .loading
return .increment
case .decrementTapped:
state.load = .loading
return .decrement
case .resetTapped:
state.load = .loading
return .reset
}
}
func run(_ effect: CounterEffect, service: CounterServicing) async -> CounterAction {
do {
switch effect {
case .increment:
return .incrementResponse(.success(try await service.increment()))
case .decrement:
return .decrementResponse(.success(try await service.decrement()))
case .reset:
return .resetResponse(.success(try await service.reset()))
}
} catch {
switch effect {
case .increment:
return .incrementResponse(.failure(error))
case .decrement:
return .decrementResponse(.failure(error))
case .reset:
return .resetResponse(.failure(error))
}
}
}
```
Adapter pattern for wiring the pure `reduce/run` pair into `Store`:
```swift
@MainActor
func makeCounterStore(service: CounterServicing) -> Store<CounterState, CounterIntent, CounterAction> {
Store(
initial: CounterState(),
reduceIntent: { state, intent in
guard let effect = reduce(state: &state, intent: intent) else { return nil }
return .run {
await run(effect, service: service)
}
},
reduceAction: { state, action in
reduce(state: &state, action: action)
}
)
}
```
## Store Pattern
- Keep store on main actor for UI mutation safety.
- Receive `Intent`, run reducer, execute `Effect`, dispatch `Action`.
- Add cancellation and request versioning for concurrent requests.
- Map all expected service failures to explicit failure actions; `onUnexpectedError` should be a bug hook, not a business-error path.
```swift
@MainActor
final class Store<State, Intent, Action>: ObservableObject {
@Published private(set) var state: State
private let reduceIntent: (inout State, Intent) -> Effect<Action>?
private let reduceAction: (inout State, Action) -> Void
private let onUnexpectedError: @MainActor (Error) -> Void
private var activeTasks: [AnyHashable: Task<Void, Never>] = [:]
init(
initial: State,
reduceIntent: @escaping (inout State, Intent) -> Effect<Action>?,
reduceAction: @escaping (inout State, Action) -> Void,
onUnexpectedError: @escaping @MainActor (Error) -> Void = { error in
assertionFailure("Unexpected unmodeled effect error: \(error)")
}
) {
self.state = initial
self.reduceIntent = reduceIntent
self.reduceAction = reduceAction
self.onUnexpectedError = onUnexpectedError
}
func send(_ intent: Intent) {
guard let effect = reduceIntent(&state, intent) else { return }
handle(effect)
}
private func handle(_ effect: Effect<Action>) {
switch effect {
case .none:
break
case .run(let operation):
Task {
do {
let action = try await operation()
reduceAction(&state, action)
} catch is CancellationError {
// Task was cancelled; no state update.
} catch {
onUnexpectedError(error)
}
}
case .cancellable(let id, let operation):
activeTasks[id]?.cancel()
activeTasks[id] = Task {
do {
let action = try await operation()
reduceAction(&state, action)
} catch is CancellationError {
// Cancelled by a newer request for the same id.
} catch {
onUnexpectedError(error)
}
activeTasks[id] = nil
}
}
}
deinit {
for task in activeTasks.values { task.cancel() }
}
}
```
Map expected service failures to explicit failure actions; reserve `onUnexpectedError` for true fallthrough faults (for example decoding bugs, violated invariants, or effect wiring mistakes). If this handler fires for normal API failures, treat that as a modeling bug and add an explicit failure action path.
## Composed Reducers
Split reducers by feature and compose them.
```swift
enum AppAction {
case counter(CounterAction)
case settings(SettingsAction)
}
func appReduce(
state: inout AppState,
intent: AppIntent,
services: AppServices
) -> Effect<AppAction>? {
switch intent {
case .counter(let counterIntent):
return counterReduce(
state: &state.counter,
intent: counterIntent,
service: services.counter
)?.map(AppAction.counter)
case .settings(let settingsIntent):
return settingsReduce(
state: &state.settings,
intent: settingsIntent,
service: services.settings
)?.map(AppAction.settings)
}
}
```
Add a `map` helper on `Effect` to lift child actions into parent actions:
```swift
extension Effect {
func map<B>(_ transform: @escaping (Action) -> B) -> Effect<B> {
switch self {
case .none:
return .none
case .run(let operation):
return .run {
let action = try await operation()
return transform(action)
}
case .cancellable(let id, let operation):
return .cancellable(id: id) {
let action = try await operation()
return transform(action)
}
}
}
}
```
Composition tradeoff: a single app-wide `AppIntent`/`AppAction` can become deeply nested as feature count grows. Prefer feature-scoped stores where practical, and compose only at flow boundaries (for example tab root, checkout flow, onboarding) instead of forcing one global mega-enum.
## View Guidance
- Render `store.state` only.
- Send user events through `store.send(intent)`.
- Never mutate domain state directly in views.
### SwiftUI Integration
```swift
struct CounterView: View {
@StateObject var store: Store<CounterState, CounterIntent, CounterAction>
var body: some View {
VStack {
Text("Count: \(store.state.count)")
if case .loading = store.state.load { ProgressView() }
Button("+") { store.send(.incrementTapped) }
Button("-") { store.send(.decrementTapped) }
Button("Reset") { store.send(.resetTapped) }
}
}
}
```
If you target iOS 17+ for SwiftUI-first features, you can replace `ObservableObject`/`@Published` stores with `@Observable` stores (as in the MVVM playbook) and use `@State` + `@Bindable` in views. Keep `ObservableObject` when the same store must expose Combine publishers to UIKit.
### UIKit Integration
In UIKit, subscribe once, render from state, and map control events to intents.
```swift
import Combine
import UIKit
final class CounterViewController: UIViewController {
private let store: Store<CounterState, CounterIntent, CounterAction>
private var cancellables = Set<AnyCancellable>()
init(store: Store<CounterState, CounterIntent, CounterAction>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
store.$state
.receive(on: RunLoop.main)
.sink { [weak self] in self?.render($0) }
.store(in: &cancellables)
}
@objc private func incrementTapped() {
store.send(.incrementTapped)
}
private func render(_ state: CounterState) {
title = "Count: \(state.count)"
// Update labels/buttons/loading from state only.
}
}
```
UIKit rules:
- keep all UI writes in `render(_:)`
- convert delegate/target-action callbacks into `Intent`
## Concurrency Rules
- Track active tasks by intent/effect key where duplicate requests are possible.
- Cancel stale in-flight work before starting a newer request.
- Use request IDs when responses can arrive out-of-order.
- Keep shared mutable service state in actors.
## Anti-Patterns and Fixes
1. Side effects inside reducer:
- Smell: analytics/network calls directly in reducer branch.
- Fix: emit `Effect` and handle through action loop.
2. Intent and action merged:
- Smell: one enum for both user input and effect output.
- Fix: separate `Intent` and `Action`.
3. Multiple sources of truth:
- Smell: local `@State` mirrors store state.
- Fix: keep canonical state in store only.
4. Derived fields stored redundantly:
- Smell: persisted `isEven` with `count`.
- Fix: compute derived properties.
5. Monolithic reducer:
- Smell: very large switch spanning unrelated domains.
- Fix: split reducers by feature and combine.
## Testing Expectations
- Unit test intent reducer transitions.
- Unit test action reducer success/failure transitions.
- Verify cancellation and stale-response handling.
- Keep tests deterministic with controlled services, schedulers, or clocks.
- Assert state-machine behavior, not view details.
Example test suite:
```swift
import XCTest
struct StubCounterService: CounterServicing {
func increment() async throws -> Int { 1 }
func decrement() async throws -> Int { 0 }
func reset() async throws -> Int { 0 }
}
final class CounterReducerTests: XCTestCase {
func test_intentIncrement_setsLoading_andReturnsEffect() {
var state = CounterState()
let service = StubCounterService()
let effect = reduce(
state: &state,
intent: .incrementTapped,
service: service
)
XCTAssertEqual(state.load, .loading)
XCTAssertNotNil(effect)
}
func test_actionFailure_setsError_andStopsLoading() {
var state = CounterState(load: .loaded(3))
reduce(state: &state, action: .incrementResponse(.failure(TestError.offline)))
XCTAssertEqual(state.count, 0)
if case .failed = state.load {
// expected
} else {
XCTFail("Expected failed state")
}
}
}
struct SearchState: Equatable {
var latestRequestID: UUID?
var results: [String] = []
}
enum SearchAction {
case response(requestID: UUID, Result<[String], Error>)
}
func reduce(state: inout SearchState, action: SearchAction) {
switch action {
case .response(let requestID, .success(let results)):
guard requestID == state.latestRequestID else { return }
state.results = results
case .response:
break
}
}
final class SearchReducerTests: XCTestCase {
func test_matchingLatestRequest_updatesResults() {
let requestID = UUID()
var state = SearchState(latestRequestID: requestID, results: [])
reduce(
state: &state,
action: .response(requestID: requestID, .success(["new"]))
)
XCTAssertEqual(state.results, ["new"])
}
func test_staleResponse_isIgnored() {
let latestID = UUID()
let staleID = UUID()
var state = SearchState(latestRequestID: latestID, results: ["current"])
reduce(
state: &state,
action: .response(requestID: staleID, .success(["old"]))
)
XCTAssertEqual(state.results, ["current"])
}
}
private enum TestError: Error {
case offline
}
```
## When to Prefer MVI
Prefer MVI for:
- complex state machines
- heavy concurrency/effect orchestration
- high determinism and testability requirements
Prefer MVVM when:
- screen complexity is moderate
- lower boilerplate is more important than strict state-machine modeling
## PR Review Checklist
- State is value-based and canonical.
- Reducers are deterministic and side-effect free.
- Effects are isolated and mapped back into actions.
- Cancellation/versioning exists for concurrent requests.
- View sends intents only; no direct business mutation.
- Reducer tests cover success, failure, and cancellation.

View File

@@ -0,0 +1,410 @@
# MVP Playbook (Swift + SwiftUI/UIKit)
Use this reference when you need a passive View that delegates all logic to a Presenter, especially in UIKit codebases where direct testability of presentation logic is a priority.
## Core Boundaries
- Model: Domain entities and business rules. No UI dependencies.
- View: Passive renderer driven entirely by Presenter commands. Owns no logic.
- Presenter: Owns all presentation logic, maps Model data to display output, and drives View updates through a protocol.
- Services/Repositories: Side-effect boundaries (network, persistence) injected into Presenter.
Dependency direction:
```text
View -> Presenter (user actions)
Presenter -> View (via ViewProtocol, one-way commands)
Presenter -> Repository/Service (via protocols)
```
The key difference from MVVM: the View holds no observable state — it passively executes commands dispatched by the Presenter.
## Feature Structure
```text
App/
Features/
Profile/
ProfileViewController.swift (View)
ProfilePresenter.swift
ProfileViewProtocol.swift
ProfileViewData.swift
ProfileAssembly.swift
Navigation/
AppCoordinator.swift
Domain/
Entities/
Repositories/
Data/
Repositories/
API/
```
## View Protocol
Define the View as a weak protocol. The Presenter drives state through it.
```swift
@MainActor
protocol ProfileView: AnyObject {
func showLoading(_ isLoading: Bool)
func show(profile: ProfileViewData)
func showError(message: String)
}
```
Rules:
- use `AnyObject` to allow weak references
- methods represent view commands, not state flags
- keep the protocol focused — one command per distinct UI concern
## View Data
Map domain entities to display-ready values in the Presenter, not the View.
```swift
struct ProfileViewData: Equatable {
let displayName: String
let badgeText: String?
let formattedJoinDate: String
}
```
## Presenter Pattern
Own task management, cancel stale work, and gate updates by request identity.
```swift
@MainActor
final class ProfilePresenter {
weak var view: ProfileView?
private let repository: ProfileRepository
private var loadTask: Task<Void, Never>?
private var latestRequestID: UUID?
init(repository: ProfileRepository) {
self.repository = repository
}
func viewDidAppear() {
load()
}
func load() {
let requestID = UUID()
latestRequestID = requestID
loadTask?.cancel()
view?.showLoading(true)
loadTask = Task {
do {
let user = try await repository.fetchCurrentUser()
try Task.checkCancellation()
guard latestRequestID == requestID else { return }
let viewData = ProfileViewData(user: user)
view?.show(profile: viewData)
} catch is CancellationError {
// Cancelled by a newer request do not update view.
} catch {
guard latestRequestID == requestID else { return }
view?.showError(message: "Failed to load profile. Please try again.")
}
guard latestRequestID == requestID else { return }
view?.showLoading(false)
}
}
deinit {
loadTask?.cancel()
}
}
extension ProfileViewData {
init(user: User) {
self.displayName = user.name
self.badgeText = user.isPremium ? "Premium" : nil
self.formattedJoinDate = user.joinDate.formatted(.dateTime.year().month())
}
}
```
Rules:
- `view` is `weak` to avoid retain cycles
- cancel in-flight task before starting a new one
- gate state updates by `requestID` to prevent stale overwrites
## UIKit View Implementation
The UIKit view controller forwards actions to Presenter and executes view commands.
```swift
@MainActor
final class ProfileViewController: UIViewController, ProfileView {
private let presenter: ProfilePresenter
private let nameLabel = UILabel()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let errorLabel = UILabel()
init(presenter: ProfilePresenter) {
self.presenter = presenter
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
presenter.viewDidAppear()
}
// MARK: - ProfileView
func showLoading(_ isLoading: Bool) {
isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
}
func show(profile: ProfileViewData) {
nameLabel.text = profile.displayName
errorLabel.isHidden = true
}
func showError(message: String) {
errorLabel.text = message
errorLabel.isHidden = false
}
private func setupLayout() {
// Layout setup omitted for brevity.
}
}
```
## SwiftUI Adapter
For SwiftUI, bridge via a thin observable adapter that conforms to `ProfileView`.
```swift
@MainActor
@Observable
final class ProfileViewAdapter: ProfileView {
private(set) var viewData: ProfileViewData?
private(set) var isLoading = false
private(set) var errorMessage: String?
private let presenter: ProfilePresenter
init(presenter: ProfilePresenter) {
self.presenter = presenter
presenter.view = self
}
func showLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
func show(profile: ProfileViewData) {
self.viewData = profile
self.errorMessage = nil
}
func showError(message: String) {
self.errorMessage = message
}
func viewDidAppear() { presenter.viewDidAppear() }
}
struct ProfileScreen: View {
@State private var adapter: ProfileViewAdapter
init(adapter: ProfileViewAdapter) {
self._adapter = State(initialValue: adapter)
}
var body: some View {
Group {
if adapter.isLoading {
ProgressView()
} else if let viewData = adapter.viewData {
VStack(alignment: .leading, spacing: 8) {
Text(viewData.displayName).font(.title)
if let badge = viewData.badgeText {
Text(badge).font(.caption)
}
}
} else if let error = adapter.errorMessage {
Text(error).foregroundStyle(.red)
}
}
.onAppear { adapter.viewDidAppear() }
}
}
```
## Assembly
Wire dependencies in one place — the assembler or coordinator.
```swift
enum ProfileAssembly {
static func build(repository: ProfileRepository) -> UIViewController {
let presenter = ProfilePresenter(repository: repository)
let viewController = ProfileViewController(presenter: presenter)
presenter.view = viewController
return viewController
}
@MainActor
static func buildSwiftUI(repository: ProfileRepository) -> ProfileScreen {
let presenter = ProfilePresenter(repository: repository)
let adapter = ProfileViewAdapter(presenter: presenter)
return ProfileScreen(adapter: adapter)
}
}
```
Rules:
- set `presenter.view` after construction, not inside the Presenter initializer
- inject concrete repositories from the composition root
- keep the assembly function as the only place that creates the full module
## Anti-Patterns and Fixes
1. View containing logic:
- Smell: UIViewController computes display strings, formats dates, or makes service calls.
- Fix: move all logic to Presenter; View receives ready-to-render view data.
2. Presenter observing state objects (ViewModel pattern leaking in):
- Smell: Presenter publishes `@Published` properties that the View observes directly.
- Fix: keep the Presenter command-driven; View state is driven by protocol method calls, not KVO or Combine pipelines.
3. Bidirectional strong references:
- Smell: Presenter holds a strong reference to View.
- Fix: declare `weak var view: ProfileView?` in Presenter.
4. No request-identity guard:
- Smell: rapid re-loads overwrite each other because any in-flight completion can update the View.
- Fix: assign a `UUID` per request and guard all view updates behind identity equality.
5. Fat Presenter:
- Smell: Presenter contains network code, caching logic, or routing details.
- Fix: delegate network and persistence to injected Repository protocols; delegate navigation to an injected Router or Coordinator.
## Testing Strategy
Test the Presenter in isolation with a mock View and stub Repository.
Verify the Presenter-to-View contract for success, failure, and cancellation paths.
Keep tests deterministic by controlling async behaviour with stubs, not `sleep`.
```swift
@MainActor
final class MockProfileView: ProfileView {
var isLoading = false
var shownViewData: ProfileViewData?
var shownError: String?
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
func show(profile: ProfileViewData) { shownViewData = profile }
func showError(message: String) { shownError = message }
}
struct StubProfileRepository: ProfileRepository {
var result: Result<User, Error>
func fetchCurrentUser() async throws -> User { try result.get() }
}
@MainActor
final class ProfilePresenterTests: XCTestCase {
func test_load_success_showsUserName() async {
let user = User(id: UUID(), name: "Alice", isPremium: false, joinDate: .now)
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .success(user))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "Alice")
XCTAssertNil(view.shownError)
}
func test_load_failure_showsError() async {
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .failure(TestError.notFound))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertNotNil(view.shownError)
XCTAssertNil(view.shownViewData)
}
func test_load_cancellation_doesNotOverwriteExistingViewData() async {
let existing = User(id: UUID(), name: "Existing", isPremium: false, joinDate: .now)
let view = MockProfileView()
view.show(profile: ProfileViewData(user: existing))
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .failure(CancellationError()))
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "Existing")
}
func test_rapidLoads_onlyLatestResultShown() async {
let firstUser = User(id: UUID(), name: "First", isPremium: false, joinDate: .now)
let view = MockProfileView()
let presenter = ProfilePresenter(
repository: StubProfileRepository(result: .success(firstUser))
)
presenter.view = view
// Simulate two rapid loads; second call cancels first.
presenter.load() // request A will be cancelled
presenter.load() // request B latest
await Task.yield()
await Task.yield()
XCTAssertEqual(view.shownViewData?.displayName, "First")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer MVP
Prefer MVP when:
- UIKit is the primary stack and you want full Presenter testability without observable state objects
- the View must be completely passive (no `if` logic, no `guard`, no formatting)
- migrating from MVC and want a minimal step up without pulling in Combine or the `@Observable` macro
- existing team is familiar with the Presenter + View protocol pattern
Prefer MVVM when:
- SwiftUI is the primary stack and `@Observable` / `@Published` state binding reduces wiring overhead
- you want reactive data flow with less hand-written command dispatch
Compared with VIPER, MVP omits the Interactor and Router as distinct components, making it lighter and simpler for single-screen features.
## PR Review Checklist
- View contains no business logic, data formatting, or service calls.
- `view` property in Presenter is `weak` and typed as `ProfileView`.
- Presenter cancels the previous task before starting a new load.
- All Presenter-to-View calls are guarded by request identity where async.
- Repository and service dependencies are injected via protocols, not singletons.
- Tests cover success, failure, and stale-cancellation paths.
- Assembly function wires the module from the outside — Presenter does not create its own dependencies.

View File

@@ -0,0 +1,769 @@
# MVVM Playbook (Swift + SwiftUI/UIKit)
Use this reference for MVVM requests or screen-level state with async effects.
## Core Boundaries
- Model: Domain entities and business rules. Keep UI-framework independent.
- View: Render state and forward user intents. Do not call services directly.
- ViewModel: Own presentation state, map domain to view data, coordinate effects.
- Services/Repositories: Side-effect boundaries (network, persistence, analytics).
Dependency direction:
- View -> ViewModel
- ViewModel -> UseCases/Repositories/Services (via protocols)
- Model -> no dependency on View/ViewModel
## Feature Structure
Prefer vertical feature slices with clear boundaries. Treat this layout as illustrative, not a required file checklist for every feature:
```text
App/
Features/
Feed/
FeedView.swift
FeedViewModel.swift
FeedState.swift
FeedViewData.swift
FeedDestination.swift
FeedAssembly.swift
Navigation/
AppRouter.swift
DeepLink.swift
Domain/
Entities/
UseCases/
Data/
Repositories/
API/
Persistence/
```
## State Modeling
Use explicit state types over boolean combinations.
```swift
enum Loadable<Value: Equatable>: Equatable {
case idle
case loading
case loaded(Value)
case failed(String)
}
struct FeedItemViewData: Identifiable, Hashable {
let id: UUID
let title: String
}
struct ToastState: Equatable {
let message: String
}
struct FeedState: Equatable {
var load: Loadable<Void> = .idle
var items: [FeedItemViewData] = []
var isRefreshing = false
var toast: ToastState?
}
```
## ViewModel Pattern
Keep mutation on main actor, own task handles, and cancel stale work.
### Modern Pattern (iOS 17+ / `@Observable`)
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
private let repository: FeedRepository
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository) {
self.repository = repository
}
func onAppear() {
guard case .idle = state.load else { return }
load()
}
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
try Task.checkCancellation()
state.items = page.items.map(FeedItemViewData.init)
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
deinit {
loadTask?.cancel()
}
}
```
### Legacy Pattern (iOS 16 and earlier / `ObservableObject`)
```swift
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var state = FeedState()
private let repository: FeedRepository
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository) {
self.repository = repository
}
func onAppear() {
guard case .idle = state.load else { return }
load()
}
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
try Task.checkCancellation()
state.items = page.items.map(FeedItemViewData.init)
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
deinit {
loadTask?.cancel()
}
}
```
## Dependency Injection
Inject abstractions into ViewModel constructors. Build live dependencies in feature assembly.
```swift
protocol FeedRepository {
func fetchPage(cursor: String?) async throws -> FeedPage
}
enum FeedAssembly {
static func makeViewModel() -> FeedViewModel {
FeedViewModel(repository: LiveFeedRepository(api: .live))
}
}
```
`FeedAssembly.makeViewModel()` keeps feature wiring obvious, but can become limiting as apps grow. A common evolution path is an app-level dependency container (composition root) that owns shared dependency graphs.
```swift
protocol AppDependencies {
var feedRepository: FeedRepository { get }
}
struct LiveDependencies: AppDependencies {
private let api: APIClient
init(api: APIClient) {
self.api = api
}
var feedRepository: FeedRepository {
LiveFeedRepository(api: api)
}
}
@MainActor
final class AppContainer {
private let dependencies: AppDependencies
init(dependencies: AppDependencies) {
self.dependencies = dependencies
}
func makeFeedViewModel() -> FeedViewModel {
FeedViewModel(repository: dependencies.feedRepository)
}
}
```
## View Guidance
- Bind to ViewModel state only.
- Keep business transforms out of `body`/`cellForRowAt`.
- Expose dedicated `ViewData` structs for formatting and display concerns.
- Keep View-local state only for transient UI details (focus, scroll position).
SwiftUI view with `@Observable` ViewModel (iOS 17+):
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
init(viewModel: FeedViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.state.items, id: \.id) { item in
Text(item.title)
}
.task { viewModel.onAppear() }
}
}
```
SwiftUI view with `ObservableObject` ViewModel (iOS 16 and earlier):
```swift
struct FeedView: View {
@StateObject private var viewModel: FeedViewModel
init(viewModel: FeedViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.state.items, id: \.id) { item in
Text(item.title)
}
.task { viewModel.onAppear() }
}
}
```
## Navigation Patterns
Keep routing decisions testable and decoupled from presentation APIs: ViewModel decides *where*, routing layer decides *how*.
### SwiftUI Navigation (iOS 16+ / `NavigationStack`)
Model destinations as an enum. Prefer stable IDs over list-specific `ViewData`.
Path ownership is a real tradeoff:
- ViewModel-owned path: simplest end-to-end SwiftUI wiring, but mixes data/loading state with navigation state.
- View-owned path: keeps ViewModel state focused on data/loading, but requires an intent API so route decisions stay testable.
- Router-owned path: best for multi-screen flows and deep links, with extra types/wiring cost.
The examples below show ViewModel-owned and router-owned patterns.
```swift
enum FeedDestination: Hashable {
case detail(id: UUID)
case profile(userId: UUID)
case settings
}
```
Option A: ViewModel-owned path.
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
var navigationPath: [FeedDestination] = []
// ...existing properties...
func didTapItem(_ item: FeedItemViewData) {
navigationPath.append(.detail(id: item.id))
}
func didTapProfile(userId: UUID) {
navigationPath.append(.profile(userId: userId))
}
}
```
View binds the path to `NavigationStack`:
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
var body: some View {
@Bindable var viewModel = viewModel
NavigationStack(path: $viewModel.navigationPath) {
List(viewModel.state.items) { item in
Button(item.title) {
viewModel.didTapItem(item)
}
}
.navigationDestination(for: FeedDestination.self) { destination in
switch destination {
case .detail(let itemID):
FeedDetailView(viewModel: FeedDetailViewModel(itemID: itemID))
case .profile(let userId):
ProfileView(viewModel: ProfileViewModel(userId: userId))
case .settings:
SettingsView(viewModel: SettingsViewModel())
}
}
.task { viewModel.onAppear() }
}
}
}
```
Option B: dedicated router keeps `FeedState` focused on presentation data/loading.
```swift
@MainActor
@Observable
final class FeedRouter {
var path: [FeedDestination] = []
func push(_ destination: FeedDestination) {
path.append(destination)
}
}
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
func destinationForItem(_ item: FeedItemViewData) -> FeedDestination {
.detail(id: item.id)
}
}
struct FeedView: View {
@State private var viewModel: FeedViewModel
@State private var router = FeedRouter()
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
List(viewModel.state.items) { item in
Button(item.title) {
router.push(viewModel.destinationForItem(item))
}
}
}
}
}
```
### Modal / Sheet Presentation
Model sheet presentation as optional state on the ViewModel.
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
var activeSheet: FeedSheet?
struct FeedFilter: Equatable {
var showUnreadOnly = false
}
enum FeedSheet: Identifiable {
case compose
case filter(current: FeedFilter)
var id: String {
switch self {
case .compose: "compose"
case .filter: "filter"
}
}
}
func didTapCompose() {
activeSheet = .compose
}
}
```
```swift
struct FeedView: View {
@State private var viewModel: FeedViewModel
var body: some View {
@Bindable var viewModel = viewModel
List(viewModel.state.items) { item in
Text(item.title)
}
.sheet(item: $viewModel.activeSheet) { sheet in
switch sheet {
case .compose:
ComposeView(viewModel: ComposeViewModel())
case .filter(let current):
FilterView(viewModel: FilterViewModel(filter: current))
}
}
}
}
```
### Coordinator Pattern (UIKit or Mixed Codebases)
When UIKit is involved or complex multi-step flows require centralized control, use a Coordinator protocol.
```swift
@MainActor
protocol FeedCoordinator: AnyObject {
func showDetail(itemID: UUID)
func showProfile(userId: UUID)
func presentCompose(onComplete: @MainActor @escaping () -> Void)
}
```
Inject the Coordinator into the ViewModel:
```swift
@MainActor
@Observable
final class FeedViewModel {
private(set) var state = FeedState()
private let repository: FeedRepository
private weak var coordinator: FeedCoordinator?
private var loadTask: Task<Void, Never>?
init(repository: FeedRepository, coordinator: FeedCoordinator) {
self.repository = repository
self.coordinator = coordinator
}
func didTapItem(_ item: FeedItemViewData) {
coordinator?.showDetail(itemID: item.id)
}
func didTapCompose() {
coordinator?.presentCompose { [weak self] in
self?.load()
}
}
}
```
Concrete implementation lives in the navigation layer:
```swift
@MainActor
final class FeedFlowCoordinator: FeedCoordinator {
private let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func showDetail(itemID: UUID) {
let viewModel = FeedDetailAssembly.makeViewModel(itemID: itemID)
let vc = UIHostingController(rootView: FeedDetailView(viewModel: viewModel))
navigationController.pushViewController(vc, animated: true)
}
func showProfile(userId: UUID) {
let viewModel = ProfileAssembly.makeViewModel(userId: userId)
let vc = UIHostingController(rootView: ProfileView(viewModel: viewModel))
navigationController.pushViewController(vc, animated: true)
}
func presentCompose(onComplete: @MainActor @escaping () -> Void) {
let composeVM = ComposeAssembly.makeViewModel(onComplete: onComplete)
let vc = UIHostingController(rootView: ComposeView(viewModel: composeVM))
navigationController.present(vc, animated: true)
}
}
```
### Deep Linking
Centralize deep link resolution in a router that maps URLs to navigation destinations.
```swift
enum DeepLink {
case feedItem(id: UUID)
case profile(userId: UUID)
case settings
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return nil }
switch host {
case "feed":
guard let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = UUID(uuidString: idString) else { return nil }
self = .feedItem(id: id)
case "profile":
guard let idString = components.queryItems?.first(where: { $0.name == "userId" })?.value,
let id = UUID(uuidString: idString) else { return nil }
self = .profile(userId: id)
case "settings":
self = .settings
default:
return nil
}
}
}
```
Apply deep links to existing navigation state:
```swift
@MainActor
@Observable
final class AppRouter {
var feedViewModel: FeedViewModel
func handle(_ deepLink: DeepLink) {
switch deepLink {
case .feedItem(let id):
feedViewModel.navigationPath = [.detail(id: id)]
case .profile(let userId):
feedViewModel.navigationPath = [.profile(userId: userId)]
case .settings:
feedViewModel.navigationPath = [.settings]
}
}
}
```
### Which Pattern to Choose
| Scenario | Recommended Pattern |
|---|---|
| Pure SwiftUI, linear flows | `NavigationStack` path on ViewModel |
| Sheets, alerts, confirmations | Optional state-driven presentation |
| UIKit host or mixed SwiftUI/UIKit | Coordinator protocol |
| Multi-step flows (onboarding, checkout) | Coordinator with child coordinators |
| Universal Links / push notifications | Deep link router + state-driven nav |
## Anti-Patterns and Fixes
1. God ViewModel:
- Smell: networking, parsing, persistence, and state orchestration all in one class.
- Fix: extract UseCases/Repositories; keep ViewModel focused on state and intent handling.
2. Duplicate state in View and ViewModel:
- Smell: `@State var items` and `viewModel.state.items` coexist.
- Fix: one source of truth in ViewModel.
3. Stale async overwrite:
- Smell: older response replaces newer state.
- Fix: cancel in-flight task before new request and check cancellation.
4. Navigation logic inside ViewModel with UIKit types:
- Smell: direct `UINavigationController` usage in ViewModel.
- Fix: inject Router/Coordinator protocol.
5. Heavy work on main actor:
- Smell: decoding or expensive mapping in main-actor methods.
- Fix: move heavy CPU work off-main; assign final state on main actor.
```swift
// Anti-pattern: expensive mapping runs on @MainActor.
@MainActor
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
state.items = page.items.map(FeedItemViewData.init) // can hitch UI for large pages
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
// Better: do CPU-heavy mapping off actor, then commit state on @MainActor.
@MainActor
func load() {
loadTask?.cancel()
state.load = .loading
loadTask = Task {
do {
let page = try await repository.fetchPage(cursor: nil)
let mappedItems = try await Task.detached(priority: .userInitiated) {
page.items.map(FeedItemViewData.init)
}.value
try Task.checkCancellation()
state.items = mappedItems
state.load = .loaded(())
} catch is CancellationError {
// Ignore cancellation.
} catch {
state.load = .failed(error.localizedDescription)
}
}
}
```
If mapping is small but reused, extract it into a pure helper (`static`/`nonisolated`) for testability; if it is expensive, run it off actor (`Task.detached` or a background service). Under strict concurrency (Swift 6), ensure detached-task captures/results are `Sendable`, or move the work behind a background actor/service boundary.
## Testing Expectations
Focus on deterministic state transitions:
- success path (`loading -> loaded`)
- failure path (`loading -> failed`)
- cancellation path (no stale overwrite)
- mapping correctness (domain -> view data)
Test strategy:
- Use protocol stubs/fakes for repositories.
- Avoid sleep-based tests; use controllable stub responses.
- If ViewModel is `@MainActor`, run assertions through `await MainActor.run`.
```swift
import XCTest
struct FeedItem: Equatable {
let id: UUID
let title: String
}
struct FeedPage: Equatable {
let items: [FeedItem]
}
extension FeedItemViewData {
init(_ item: FeedItem) {
self.id = item.id
self.title = item.title
}
}
actor ControlledFeedRepository: FeedRepository {
private var continuations: [CheckedContinuation<FeedPage, Error>] = []
func fetchPage(cursor: String?) async throws -> FeedPage {
try await withCheckedThrowingContinuation { continuation in
continuations.append(continuation)
}
}
func resolveNext(with result: Result<FeedPage, Error>) {
guard !continuations.isEmpty else { return }
let continuation = continuations.removeFirst()
switch result {
case .success(let page):
continuation.resume(returning: page)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
@MainActor
final class FeedViewModelTests: XCTestCase {
func test_load_success_setsLoadedAndMapsItems() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
let expected = FeedPage(items: [FeedItem(id: UUID(), title: "A")])
sut.load()
await repository.resolveNext(with: .success(expected))
await Task.yield()
XCTAssertEqual(sut.state.items.map(\.title), ["A"])
if case .loaded = sut.state.load {
// expected
} else {
XCTFail("Expected loaded state")
}
}
func test_load_failure_setsFailed() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
sut.load()
await repository.resolveNext(with: .failure(TestError.offline))
await Task.yield()
if case .failed = sut.state.load {
// expected
} else {
XCTFail("Expected failed state")
}
}
func test_load_cancellation_ignoresStaleResult() async {
let repository = ControlledFeedRepository()
let sut = FeedViewModel(repository: repository)
let stale = FeedPage(items: [FeedItem(id: UUID(), title: "stale")])
let latest = FeedPage(items: [FeedItem(id: UUID(), title: "latest")])
sut.load() // request A
sut.load() // request B cancels A
await repository.resolveNext(with: .success(stale))
await repository.resolveNext(with: .success(latest))
await Task.yield()
await Task.yield()
XCTAssertEqual(sut.state.items.map(\.title), ["latest"])
}
}
private enum TestError: Error {
case offline
}
```
## When to Prefer MVVM
Prefer MVVM when:
- screen-level state management is the primary concern
- team wants explicit View/ViewModel boundaries without introducing a full reducer/store framework
- feature complexity is moderate and does not require strict unidirectional flow
- the team accepts moderate structure (for example, `State`, `ViewData`, assembly/router types) in exchange for clarity and testability
MVVM is often lower ceremony than TCA/VIPER, but not "no ceremony." A strict MVVM style can introduce several files per feature; scale file splitting to actual complexity instead of applying every type up front.
Prefer MVI/TCA when:
- deterministic state-machine modeling is required
- complex effect orchestration and cancellation correctness are critical
Prefer Clean Architecture/VIPER when:
- strict layer boundaries and use-case isolation matter more than presentation-layer simplicity
## PR Review Checklist
- View does not call services directly.
- ViewModel exposes explicit state model.
- Dependencies are injected (no app-wide singleton dependency in ViewModel).
- Async tasks have cancellation strategy.
- Domain models are not directly coupled to View rendering.
- Navigation destinations are modeled as value types (enum/struct), not imperative calls.
- ViewModel does not import UIKit or reference presentation APIs directly.
- Deep link handling routes through a centralized router, not ad-hoc view logic.
- Unit tests cover success, failure, and cancellation.

View File

@@ -0,0 +1,327 @@
# Reactive Architecture Playbook (Swift + Combine/RxSwift)
Use this reference for stream-driven features (search, live updates, real-time feeds).
## Core Philosophy
Model inputs, transforms, and outputs as streams.
```text
Input -> Publisher/Observable chain -> State -> UI
```
Keep stream composition in presentation or a dedicated reactive layer, not in views.
## Canonical Combine Pattern
```swift
final class SearchViewModel<S: Scheduler>: ObservableObject
where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
@Published var query = ""
@Published private(set) var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init(service: SearchService, scheduler: S) {
$query
.debounce(for: .milliseconds(300), scheduler: scheduler)
.removeDuplicates()
.map { query in
service.search(query)
.replaceError(with: [])
}
.switchToLatest()
.receive(on: scheduler)
.sink { [weak self] values in
self?.results = values
}
.store(in: &cancellables)
}
}
```
In production, pass `DispatchQueue.main` as the scheduler.
Rules:
- debounce user text input
- remove duplicates where meaningful
- hop to main thread before UI-bound state writes
- keep cancellables tied to lifecycle
## UI Integration by Stack
### SwiftUI Pattern
- Keep operator chains in `ObservableObject`/`@Observable` types, not in `View`.
- Bind UI input (`TextField`, toggle, selection) to published inputs on the model.
### UIKit Pattern (Combine)
- Keep pipelines in Presenter/ViewModel.
- Map delegate/target-action callbacks into input subjects.
- Render from a single state subscription.
```swift
import Combine
import UIKit
@MainActor
final class SearchPresenter<S: Scheduler> where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
let state = CurrentValueSubject<SearchResultState, Never>(.loaded([]))
private let query = PassthroughSubject<String, Never>()
private var cancellables = Set<AnyCancellable>()
init(service: SearchService, scheduler: S) {
query
.debounce(for: .milliseconds(300), scheduler: scheduler)
.removeDuplicates()
.map { value in
service.search(value)
.map(SearchResultState.loaded)
.catch { Just(.failed($0.localizedDescription)) }
}
.switchToLatest()
.sink { [weak self] in self?.state.send($0) }
.store(in: &cancellables)
}
func queryChanged(_ text: String) { query.send(text) }
}
// In production, pass DispatchQueue.main as the scheduler.
final class SearchViewController: UIViewController, UISearchBarDelegate {
private let presenter: SearchPresenter<DispatchQueue>
private var cancellables = Set<AnyCancellable>()
init(presenter: SearchPresenter<DispatchQueue>) {
self.presenter = presenter
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
presenter.state
.sink { [weak self] in self?.render($0) }
.store(in: &cancellables)
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
presenter.queryChanged(searchText)
}
private func render(_ state: SearchResultState) {
// Render labels/list/error from stream state.
}
}
```
## Operator Guidance
- `debounce`: stabilize noisy user input (search fields)
- `throttle`: limit high-frequency events (scroll, sensor)
- `flatMap`: merge concurrent async work when all responses matter
- `switchToLatest`: keep only newest request (typeahead/search)
- `share`: avoid duplicate side effects for multiple subscribers
- `catch`: recover from recoverable errors with fallback streams
Prefer `switchToLatest` over nested subscriptions for request replacement flows.
## RxSwift Mapping Notes
Combine and RxSwift mapping:
- `AnyPublisher` <-> `Observable`
- `AnyCancellable` <-> `DisposeBag`
- `receive(on:)` <-> `observe(on:)`
- `subscribe(on:)` semantics should be applied intentionally to offload heavy work
## Error Handling Pattern
Recover in stream boundaries and expose user-safe state:
```swift
protocol SearchService {
func search(_ query: String) -> AnyPublisher<[String], Error>
}
enum SearchResultState: Equatable {
case loaded([String])
case failed(String)
}
func searchState(
query: String,
service: SearchService
) -> AnyPublisher<SearchResultState, Never> {
service.search(query)
.map(SearchResultState.loaded)
.catch { Just(.failed($0.localizedDescription)) }
.eraseToAnyPublisher()
}
```
For transient failures, prefer fallback state over terminating the stream.
## Anti-Patterns and Fixes
1. Nested subscriptions:
- Smell: subscribe inside subscribe, difficult cancellation and reasoning.
- Fix: compose with `flatMap`/`switchToLatest`.
2. Missing cancellation/disposal:
- Smell: stream continues after screen deallocation or rebind.
- Fix: store `AnyCancellable` or use `DisposeBag` lifecycle correctly.
3. Business logic in view:
- Smell: view constructs pipelines and calls services directly.
- Fix: move stream orchestration to Presenter/ViewModel layer.
4. UI thread violations:
- Smell: publishing UI-bound state off main thread.
- Fix: apply `receive(on:)` / `observe(on:)` before UI mutations.
5. Unbounded fan-out:
- Smell: many subscribers trigger duplicate network calls.
- Fix: use `share`/multicasting where side effects should be single-execution.
## Testing Strategy
Test stream behavior deterministically:
- input -> expected output transitions
- success path emits the expected state sequence
- debounce/throttle behavior with controlled schedulers
- cancellation behavior for replaced requests
- error fallback behavior
Rules:
- inject schedulers/time providers for tests
- avoid real-time sleeps when possible
- assert emitted state sequence, not internal operator details
```swift
import Combine
import CombineSchedulers
import XCTest
final class SearchViewModelTests: XCTestCase {
func test_queryEmitsResults() {
let subject = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
// Requires Point-Free's CombineSchedulers package.
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "swift"
// Advance past debounce interval.
scheduler.advance(by: .milliseconds(300))
// Simulate service response.
subject.send(["SwiftUI", "Swift"])
subject.send(completion: .finished)
// Advance to process receive(on:).
scheduler.advance()
XCTAssertEqual(collected, [["SwiftUI", "Swift"]])
cancellable.cancel()
}
func test_errorFallsBackToEmptyResults() {
let subject = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "swift"
scheduler.advance(by: .milliseconds(300))
subject.send(completion: .failure(TestError.offline))
scheduler.advance()
XCTAssertEqual(collected, [[]])
cancellable.cancel()
}
func test_switchToLatest_ignoresStaleInFlightResponse() {
let first = PassthroughSubject<[String], Error>()
let second = PassthroughSubject<[String], Error>()
let stubService = StubSearchService { query in
switch query {
case "sw":
return first.eraseToAnyPublisher()
case "swift":
return second.eraseToAnyPublisher()
default:
return Empty<[String], Error>().eraseToAnyPublisher()
}
}
let scheduler = DispatchQueue.test
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
var collected: [[String]] = []
let cancellable = vm.$results
.dropFirst()
.sink { collected.append($0) }
vm.query = "sw"
scheduler.advance(by: .milliseconds(300))
vm.query = "swift"
scheduler.advance(by: .milliseconds(300))
// This should be ignored because a newer query replaced the subscription.
first.send(["stale"])
second.send(["fresh"])
scheduler.advance()
XCTAssertEqual(collected, [["fresh"]])
cancellable.cancel()
}
}
struct StubSearchService: SearchService {
let searchHandler: (String) -> AnyPublisher<[String], Error>
func search(_ query: String) -> AnyPublisher<[String], Error> {
searchHandler(query)
}
}
private enum TestError: Error {
case offline
}
```
The canonical `SearchViewModel` already supports scheduler injection for tests.
## When to Prefer Reactive Architecture
Prefer when:
- feature is event-heavy and stream-oriented
- real-time updates and transformations are core behavior
- composable async pipelines provide clarity over imperative callbacks
Prefer MVI/TCA when:
- explicit state-machine and strict reducer flow are primary requirements
## PR Review Checklist
- Streams are composed without nested subscriptions.
- Cancellation/disposal is lifecycle-safe.
- UI-bound updates are marshaled to main thread.
- Operators match intent (`debounce`, `throttle`, `switchToLatest`, `share`).
- Views/controllers do not hold business pipeline logic.
- Error handling keeps UX resilient for transient failures.

View File

@@ -0,0 +1,141 @@
# Architecture Selection Guide
Use this reference when the user asks for an architecture recommendation.
## Decision Matrix
| Factor | MVVM | MVI | TCA | Clean | VIPER | Reactive | MVP | Coordinator |
|--------|------|-----|-----|-------|-------|----------|-----|-------------|
| State complexity | LowMed | High | High | MedHigh | Med | Med | LowMed | N/A (navigation layer) |
| Unidirectional flow | Optional | Strict | Strict | N/A | N/A | Stream-based | Optional | N/A |
| Composition / modularity | Feature-level | Feature-level | Strong (Scope/forEach) | Layer-level | Module-level | Operator-level | Feature-level | Flow-level |
| Testing determinism | Good | Very high | Very high (TestStore) | Good | Good | Good (with schedulers) | Good | Good |
| Boilerplate | Low | Medium | MediumHigh | MediumHigh | High | LowMedium | Medium | LowMedium |
| SwiftUI fit | Excellent | Good | Excellent | Good | Fair (UIKit-native) | Good | Fair | Good |
| UIKit fit | Good | Good | Good | Good | Excellent | Good | Excellent | Excellent |
| Team learning curve | Low | Medium | High | Medium | MediumHigh | Medium | Low | Low |
| Async/effect orchestration | Manual | Structured | Built-in | Manual | Manual | Operator-driven | Manual | N/A |
| Framework dependency | None | None | swift-composable-architecture | None | None | Combine or RxSwift | None | None |
## UI Stack Nuance by Architecture
- **MVVM**: SwiftUI favors direct state binding; UIKit/mixed favors coordinator-driven navigation.
- **MVI**: SwiftUI uses store-bound views; UIKit maps events to intents and renders from store state.
- **TCA**: SwiftUI uses `StoreOf` in views; UIKit uses a controller render loop from `ViewStore`.
- **Clean Architecture**: Domain/data stay the same; only presentation adapters differ.
- **VIPER**: UIKit-native fit; SwiftUI usually uses an adapter plus `UIHostingController`.
- **Reactive**: SwiftUI keeps pipelines in observable models; UIKit keeps them in Presenter/ViewModel.
- **MVP**: UIKit-native fit; Presenter drives passive View via protocol commands; SwiftUI uses an observable adapter.
- **Coordinator**: Works with both stacks; UIKit uses `UINavigationController` wrapper; SwiftUI models navigation as value-type state bound to `NavigationStack`.
## Quick Decision Flow
```text
1. Is the feature stream-heavy (search, live feeds, real-time updates)?
YES -> Consider Reactive (references/reactive.md). If strict reducer/state-machine flow is also required, continue to step 2 and likely combine patterns.
NO -> Continue
2. Is strict unidirectional data flow and state-machine modeling required?
YES -> Is the app already TCA-based, or is adding TCA dependency acceptable?
YES -> TCA (references/tca.md)
NO -> MVI (references/mvi.md)
NO -> Continue
3. Does the codebase need strict layer isolation with replaceable infrastructure?
YES -> Clean Architecture (references/clean-architecture.md)
NO -> Continue
4. Is this a large UIKit codebase needing strict per-feature separation?
YES -> VIPER (references/viper.md)
NO -> Continue
5. Is the primary goal decoupling navigation from screens (deep linking, reusable flows)?
YES -> Coordinator (references/coordinator.md) — pair with a presentation pattern below
NO -> Continue
6. Is UIKit the primary stack and a fully passive View with zero logic desired?
YES -> MVP (references/mvp.md)
NO -> Continue
7. Default recommendation:
-> MVVM (references/mvvm.md)
```
## Inference from User Constraints
Use these request signals:
### Signals pointing to MVVM
- "simple feature", "screen-level state", "standard iOS pattern"
- small/medium feature without strict state-machine needs
### Signals pointing to MVI
- "state machine", "deterministic transitions", "unidirectional"
- need to replay/serialize state transitions
### Signals pointing to TCA
- "composable", "TestStore", "pointfree", mentions of TCA
- existing TCA codebase or strong child-feature composition needs
### Signals pointing to Clean Architecture
- "layers", "use cases", "dependency rule", "hexagonal"
- stable module boundaries and replaceable infrastructure are priorities
### Signals pointing to VIPER
- "module", "router", "presenter", legacy UIKit codebase
- strict role separation in large UIKit modules
### Signals pointing to Reactive
- "streams", "Combine", "RxSwift", "real-time", "search"
- feature behavior is event-pipeline driven (typeahead, WebSocket, live feeds)
### Signals pointing to MVP
- "passive view", "presenter drives view", "UIKit without observable state"
- migrating from MVC with minimal framework changes
- team prefers explicit command-dispatch over state binding
### Signals pointing to Coordinator
- "navigation", "deep linking", "flow", "routing", "decouple navigation"
- multiple screens need to be reused across different flows
- view controllers or ViewModels currently contain push/present calls
## Validating User-Requested Architectures
When the user pre-selects an architecture, validate it before finalizing:
1. Check fit across:
- UI stack (SwiftUI/UIKit/mixed)
- feature complexity and state model needs
- effect orchestration requirements
- team familiarity and dependency tolerance
- alignment with existing codebase conventions
2. Decide whether the request is a `fit` or a `mismatch`.
3. Respond based on the result:
- `fit`: proceed with requested architecture
- `mismatch`: recommend closest-fit alternative and explain why
If the user insists on a mismatched choice, proceed with the requested architecture but include a risk-mitigation plan.
## Combining Architectures
Some projects use multiple patterns. Common valid combinations:
- **MVVM + Reactive**: MVVM structure with Combine/Rx pipelines inside ViewModels
- **Clean Architecture + MVVM**: Clean layers for domain/data, MVVM for presentation
- **Clean Architecture + TCA**: Clean layers for domain/data, TCA for feature presentation
- **VIPER + Reactive**: VIPER module structure with reactive Interactors
- **MVVM + Coordinator**: MVVM for screen-level state, Coordinator for navigation flows
- **MVP + Coordinator**: MVP for presentation logic, Coordinator for navigation and routing
- **Clean Architecture + MVP**: Clean layers for domain/data, MVP for presentation
When combining, clarify which pattern governs which layer and keep boundaries consistent.
## Recommendation Format
When recommending:
1. Name one pattern and provide a fit result (`fit` or `mismatch`).
2. Give 1-2 concise reasons grounded in user constraints.
3. Cite the reference file.
4. If `mismatch`, include the closest-fit alternative and one trade-off.
5. Apply the selected playbook to the users feature.

View File

@@ -0,0 +1,406 @@
# TCA Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow, strong composition, and `TestStore`-driven testing.
## Mental Model
```text
View -> store.send(Action)
Reducer(State, Action) -> state mutation + Effect<Action>
Effect emits Action -> reducer
```
Core expectations:
- value-based state
- reducer-driven decisions
- isolated side effects via effects
- dependency injection through TCA dependencies
- feature composition with scoped reducers
## Canonical Feature Shape
Prefer modern TCA with `@Reducer` and `@ObservableState`.
```swift
import ComposableArchitecture
@Reducer
struct CounterFeature {
enum CancelID { case fact }
enum FactError: Error, Equatable {
case unavailable
}
@ObservableState
struct State: Equatable {
var count = 0
var isLoading = false
@Presents var alert: AlertState<Action.Alert>?
}
enum Action: Equatable {
case incrementTapped
case decrementTapped
case factButtonTapped
case factResponse(Result<String, FactError>)
case alert(PresentationAction<Alert>)
enum Alert: Equatable {}
}
@Dependency(\.numberFact) var numberFact
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
let n = state.count
return .run { send in
do {
let fact = try await numberFact.fetch(n)
await send(.factResponse(.success(fact)))
} catch is CancellationError {
// Cancellation is expected when a new request replaces this one.
} catch {
await send(.factResponse(.failure(.unavailable)))
}
}
.cancellable(id: CancelID.fact, cancelInFlight: true)
case .factResponse(.success(let fact)):
state.isLoading = false
state.alert = AlertState { TextState(fact) }
return .none
case .factResponse(.failure):
state.isLoading = false
state.alert = AlertState { TextState("Could not load fact.") }
return .none
case .alert:
return .none
}
}
.ifLet(\.$alert, action: \.alert)
}
}
```
## View Integration
Rules:
- send actions from the view
- never mutate business state directly in the view
- observe the smallest practical state slice
### Modern Pattern (TCA 1.7+ with `@ObservableState`)
With `@ObservableState`, views access store properties directly — no `WithViewStore` needed.
```swift
struct CounterView: View {
@Bindable var store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("Count: \(store.count)")
Button("+") { store.send(.incrementTapped) }
Button("-") { store.send(.decrementTapped) }
Button("Fact") { store.send(.factButtonTapped) }
if store.isLoading { ProgressView() }
}
.alert($store.scope(state: \.alert, action: \.alert))
}
}
```
### Legacy Pattern (TCA < 1.7 with `WithViewStore`)
```swift
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
Text("Count: \(viewStore.count)")
Button("+") { viewStore.send(.incrementTapped) }
Button("-") { viewStore.send(.decrementTapped) }
Button("Fact") { viewStore.send(.factButtonTapped) }
if viewStore.isLoading { ProgressView() }
}
.alert(store: store.scope(state: \.alert, action: \.alert))
}
}
}
```
UIKit guidance:
- keep a store in the view controller
- subscribe to state changes from the store
- centralize rendering in one method
Concrete UIKit pattern:
```swift
import ComposableArchitecture
import Combine
import UIKit
@MainActor
final class CounterViewController: UIViewController {
private let viewStore: ViewStoreOf<CounterFeature>
private var cancellables = Set<AnyCancellable>()
init(store: StoreOf<CounterFeature>) {
self.viewStore = ViewStore(store, observe: { $0 })
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { return nil }
override func viewDidLoad() {
super.viewDidLoad()
viewStore.publisher
.sink { [weak self] state in
self?.render(state)
}
.store(in: &cancellables)
}
@objc private func incrementTapped() {
viewStore.send(.incrementTapped)
}
private func render(_ state: CounterFeature.State) {
title = "Count: \(state.count)"
// Render labels/buttons/loading from state only.
}
}
```
## Composition Patterns
Use `Scope` for parent-child composition.
```swift
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var counter = CounterFeature.State()
}
enum Action: Equatable {
case counter(CounterFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.counter, action: \.counter) {
CounterFeature()
}
}
}
```
Use `IdentifiedArrayOf` and `forEach` for collections with stable identity.
## Dependency Rules
- keep dependency surfaces small and capability-focused
- inject via `@Dependency`
- never place dependencies in state
- avoid singleton calls in reducers
```swift
struct NumberFactClient {
var fetch: @Sendable (Int) async throws -> String
}
extension NumberFactClient: DependencyKey {
static let liveValue = Self(fetch: { number in
"\(number) is a good number."
})
static let testValue = Self(fetch: { _ in
"Test fact"
})
}
extension DependencyValues {
var numberFact: NumberFactClient {
get { self[NumberFactClient.self] }
set { self[NumberFactClient.self] = newValue }
}
}
```
## Effects and Concurrency
Use `.run` for async work and route results back as actions.
For re-entrant work, add cancellation (`.cancellable(id:cancelInFlight:)`) and map failures to explicit actions.
If cancellation is not enough, add request versioning.
## Navigation Pattern
Model navigation in state and drive it through actions.
Common shapes:
- `@Presents var alert: AlertState<Action.Alert>?`
- `destination: Destination.State?`
- Attach a matching `.ifLet` reducer for each presentation action (`alert`, `destination`, etc.).
Keep navigation decisions in reducers and keep views declarative.
## Testing with `TestStore`
Use `TestStore` for deterministic action/state assertions.
Cover success, failure, and cancellation paths in async effects.
```swift
import XCTest
import ComposableArchitecture
@MainActor
final class CounterFeatureTests: XCTestCase {
func testIncrement() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementTapped) {
$0.count = 1
}
}
func testFactSuccess() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in "42 is great" }
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(.factResponse(.success("42 is great"))) {
$0.isLoading = false
$0.alert = AlertState { TextState("42 is great") }
}
}
func testFactFailure() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in throw CounterFeature.FactError.unavailable }
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(.factResponse(.failure(.unavailable))) {
$0.isLoading = false
$0.alert = AlertState { TextState("Could not load fact.") }
}
}
func testFactCancellation_replacesInFlightRequest() async {
let clock = TestClock()
actor Sequence {
var values = ["first", "second"]
func next() -> String { values.removeFirst() }
}
let sequence = Sequence()
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { _ in
let value = await sequence.next()
try await clock.sleep(for: .seconds(1))
return value
}
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.send(.factButtonTapped)
await clock.advance(by: .seconds(1))
await store.receive(.factResponse(.success("second"))) {
$0.isLoading = false
$0.alert = AlertState { TextState("second") }
}
}
}
```
## Anti-Patterns and Fixes
1. Massive feature with no composition:
- Smell: giant reducer handling unrelated domains.
- Fix: split into child reducers and compose via `Scope`.
2. Reference types in state:
- Smell: class instances or shared mutable collections in state.
- Fix: keep state value-based and equatable.
3. Business work in views:
- Smell: view calls services or transforms domain data.
- Fix: move logic to reducer/effects and expose render-ready state.
4. Side effects directly in reducer:
- Smell: analytics/network calls inline without effect boundary.
- Fix: route through dependencies and effects.
5. Duplicate state outside store:
- Smell: local `@State` mirrors store state.
- Fix: keep single source of truth in store.
6. Over-observing large state:
- Smell: broad observation triggers unnecessary re-renders.
- Fix: observe scoped state and split view/store boundaries.
7. Missing cancellation:
- Smell: overlapping effects overwrite current intent.
- Fix: use `.cancellable(id:cancelInFlight:)` and request IDs when needed.
## When to Prefer TCA
Prefer TCA when:
- app has many stateful workflows
- test determinism is critical
- composition and modular scaling are required
- effect cancellation correctness matters
Prefer MVVM or lighter MVI variants when:
- app is small and unlikely to grow
- team is not ready for UDF discipline
- feature speed and low ceremony are prioritized
## PR Review Checklist
- State is value-based and equatable.
- Reducer avoids direct side effects.
- Dependencies are injected and overrideable in tests.
- Effects have cancellation strategy where needed.
- Features compose with `Scope`/`forEach`.
- Navigation is modeled in state.
- Tests cover success, failure, and cancellation flows.
- Views render and send actions only.

View File

@@ -0,0 +1,491 @@
# VIPER Playbook (Swift + SwiftUI/UIKit)
Use this reference when strict feature-level separation is needed, especially in large or legacy UIKit codebases.
## Core Components
- View: render UI and forward user actions
- Interactor: execute business logic and coordinate data access
- Presenter: transform entities into display-ready output and control view state
- Entity: domain models used by the feature
- Router: navigation and module assembly
Expected interaction:
```text
View -> Presenter -> Interactor -> Repository/Service -> Interactor -> Presenter -> View
Presenter -> Router (navigation)
```
## Canonical Feature Layout
```text
Feature/
View/
Presenter/
Interactor/
Entity/
Router/
```
Keep one VIPER module per feature to prevent cross-feature leakage.
## Responsibilities
### View
- Render data provided by Presenter.
- Forward user inputs (`didTap...`, `didAppear`, text changes).
- Avoid direct service/repository access.
- In SwiftUI, use an adapter (`@Observable` on iOS 17+ or `ObservableObject` when Combine/UIKit interop is needed) that forwards to Presenter.
### Presenter
- Own presentation flow for the feature.
- Ask Interactor for business results.
- Map entities to view models/display strings.
- Call Router for navigation.
### Interactor
- Execute business rules and use cases.
- Call repositories/services through protocols.
- Return domain results to Presenter.
- Avoid direct view or navigation concerns.
### Router
- Perform navigation transitions.
- Build and wire module dependencies.
### Entity
- Represent domain data and business invariants.
- Avoid UI and framework coupling where possible.
- Keep display formatting out of `Entity`; Presenter maps entity -> display model.
```swift
struct User: Equatable {
let id: UUID
let name: String
let isPremium: Bool
}
struct ProfileViewData: Equatable {
let displayName: String
let badgeText: String?
}
extension ProfileViewData {
init(user: User) {
self.displayName = user.name
self.badgeText = user.isPremium ? "Premium" : nil
}
}
```
## Wiring Pattern
Use boundary protocols and directional references.
```swift
@MainActor
protocol ProfileView: AnyObject {
func showLoading(_ isLoading: Bool)
func show(profile: ProfileViewData)
func showError(message: String)
}
protocol ProfileInteracting {
func loadUser() async throws -> User
}
protocol ProfileRouting {
func showSettings()
}
@MainActor
final class ProfilePresenter {
weak var view: ProfileView?
private let interactor: ProfileInteracting
private let router: ProfileRouting
private var loadTask: Task<Void, Never>?
private var latestLoadRequestID: UUID?
init(interactor: ProfileInteracting, router: ProfileRouting) {
self.interactor = interactor
self.router = router
}
func load() {
let requestID = UUID()
latestLoadRequestID = requestID
loadTask?.cancel()
view?.showLoading(true)
loadTask = Task {
do {
let user = try await interactor.loadUser()
try Task.checkCancellation()
guard latestLoadRequestID == requestID else { return }
view?.show(profile: ProfileViewData(user: user))
} catch is CancellationError {
// Cancelled by a newer load request.
} catch {
guard latestLoadRequestID == requestID else { return }
view?.showError(message: "Failed to load profile. Please try again.")
}
guard latestLoadRequestID == requestID else { return }
view?.showLoading(false)
}
}
func didTapSettings() {
router.showSettings()
}
deinit {
loadTask?.cancel()
}
}
```
Keep `view` weak to avoid retain cycles.
Keep presenter/view updates on the main actor so UI calls are thread-safe.
## Assembly Guidance
Create modules via Router/Assembly factory:
- instantiate View, Presenter, Interactor, Router
- inject protocols, not concrete global singletons
- set references once during build
This centralizes wiring and reduces circular dependency mistakes.
```swift
enum ProfileModule {
static func build(
userRepository: UserRepository,
navigationController: UINavigationController
) -> UIViewController {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileRouter(navigationController: navigationController)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let viewController = ProfileViewController(presenter: presenter)
presenter.view = viewController
return viewController
}
}
```
Rules:
- keep the factory method as the single entry point for module creation
- inject external dependencies (repositories, services) from the caller
- set weak back-references (e.g., `presenter.view`) after construction
SwiftUI integration option:
- keep Presenter/Interactor/Router unchanged
- wrap SwiftUI feature view in `UIHostingController`
- bridge Presenter output through a small adapter object
- for pure SwiftUI apps, inject a SwiftUI router object instead of requiring `UINavigationController`
```swift
import SwiftUI
import UIKit
@MainActor
final class ProfileViewAdapter: ObservableObject, ProfileView {
@Published private(set) var name = ""
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let presenter: ProfilePresenter
init(presenter: ProfilePresenter) {
self.presenter = presenter
}
func showLoading(_ isLoading: Bool) {
self.isLoading = isLoading
}
func show(profile: ProfileViewData) {
self.name = profile.displayName
self.errorMessage = nil
}
func showError(message: String) {
self.errorMessage = message
}
func load() { presenter.load() }
func didTapSettings() { presenter.didTapSettings() }
}
struct ProfileScreen: View {
@ObservedObject var adapter: ProfileViewAdapter
var body: some View {
VStack {
Text(adapter.name)
if adapter.isLoading { ProgressView() }
if let errorMessage = adapter.errorMessage {
Text(errorMessage)
}
Button("Settings") { adapter.didTapSettings() }
}
.task { adapter.load() }
}
}
enum ProfileModuleSwiftUI {
static func build(
userRepository: UserRepository,
navigationController: UINavigationController
) -> UIViewController {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileRouter(navigationController: navigationController)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let adapter = ProfileViewAdapter(presenter: presenter)
presenter.view = adapter
return UIHostingController(rootView: ProfileScreen(adapter: adapter))
}
}
```
Pure SwiftUI app option (no `UINavigationController`):
```swift
import SwiftUI
enum AppDestination: Hashable {
case settings
}
@MainActor
@Observable
final class AppRouter {
var path: [AppDestination] = []
func push(_ destination: AppDestination) {
path.append(destination)
}
}
@MainActor
final class ProfileSwiftUIRouter: ProfileRouting {
private let appRouter: AppRouter
init(appRouter: AppRouter) {
self.appRouter = appRouter
}
func showSettings() {
appRouter.push(.settings)
}
}
enum ProfileModulePureSwiftUI {
@MainActor
static func build(
userRepository: UserRepository,
appRouter: AppRouter
) -> ProfileScreen {
let interactor = ProfileInteractor(repository: userRepository)
let router = ProfileSwiftUIRouter(appRouter: appRouter)
let presenter = ProfilePresenter(interactor: interactor, router: router)
let adapter = ProfileViewAdapter(presenter: presenter)
presenter.view = adapter
return ProfileScreen(adapter: adapter)
}
}
```
At app root, bind the shared router path to `NavigationStack`:
```swift
struct AppRootView: View {
@State private var appRouter = AppRouter()
var body: some View {
@Bindable var appRouter = appRouter
NavigationStack(path: $appRouter.path) {
ProfileModulePureSwiftUI.build(
userRepository: LiveUserRepository(),
appRouter: appRouter
)
.navigationDestination(for: AppDestination.self) { destination in
switch destination {
case .settings:
SettingsView()
}
}
}
}
}
```
## Concurrency and Cancellation
When Presenter coordinates async work, track active tasks and cancel stale requests. The `ProfilePresenter` shown in the Wiring Pattern section above already implements the full cancellation strategy — it holds a `loadTask: Task<Void, Never>?`, a `latestLoadRequestID: UUID?`, and handles `CancellationError` explicitly to guard against stale UI updates.
Rules:
- cancel in-flight tasks before issuing new requests
- handle `CancellationError` explicitly to avoid stale UI updates
- gate UI updates by request identity so only the latest request can update view state
- cancel all tasks on module teardown
- keep presenter intent methods synchronous (`func load()`), and manage async tasks internally
## Anti-Patterns and Fixes
1. Massive Presenter:
- Smell: presenter contains business logic, formatting, networking, and navigation details.
- Fix: move business logic to Interactor and formatting helpers; keep Presenter orchestration-focused.
2. Interactor performing navigation:
- Smell: interactor directly pushes/presents screens.
- Fix: route navigation through Router called by Presenter.
3. Circular dependencies and strong cycles:
- Smell: View <-> Presenter <-> Router retain each other strongly.
- Fix: use boundary protocols and weak references where required.
4. View doing business work:
- Smell: View transforms data or calls services directly.
- Fix: move logic into Presenter/Interactor.
5. Router containing business logic:
- Smell: Router decides domain outcomes.
- Fix: keep Router limited to navigation and assembly.
## Testing Strategy
Prioritize isolated tests per component:
- Presenter tests with mocked View/Interactor/Router
- Interactor tests with mocked repositories/services
- Router tests for navigation triggers where feasible
Testing rules:
- assert interactions and outputs, not concrete implementations
- avoid network in unit tests
- verify presenter handles success and failure states
- verify Presenter-to-View error contract (`showError(message:)`) for failure paths
- test cancellation behavior when a newer load replaces an in-flight request
- keep async tests deterministic with controlled stubs/clocks (avoid sleeps)
Use the cancellation-aware presenter from the "Concurrency and Cancellation" section for cancellation-path tests.
```swift
@MainActor
final class MockProfileView: ProfileView {
var shownName: String?
var shownError: String?
var isLoading = false
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
func show(profile: ProfileViewData) {
shownName = profile.displayName
}
func showError(message: String) {
shownError = message
}
}
struct StubProfileInteractor: ProfileInteracting {
var load: () async throws -> User
func loadUser() async throws -> User { try await load() }
}
final class SpyProfileRouter: ProfileRouting {
var didShowSettings = false
func showSettings() { didShowSettings = true }
}
@MainActor
final class ProfilePresenterTests: XCTestCase {
func test_load_success_showsUserName() async {
let user = User(id: UUID(), name: "Alice", isPremium: false)
let view = MockProfileView()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { user }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownName, "Alice")
}
func test_load_failure_showsError() async {
let view = MockProfileView()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { throw TestError.notFound }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownError, "Failed to load profile. Please try again.")
}
func test_didTapSettings_routesToSettings() {
let router = SpyProfileRouter()
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { User(id: UUID(), name: "", isPremium: false) }),
router: router
)
presenter.didTapSettings()
XCTAssertTrue(router.didShowSettings)
}
func test_load_cancellation_doesNotOverwriteExistingName() async {
let view = MockProfileView()
view.shownName = "Current"
let presenter = ProfilePresenter(
interactor: StubProfileInteractor(load: { throw CancellationError() }),
router: SpyProfileRouter()
)
presenter.view = view
presenter.load()
await Task.yield()
XCTAssertEqual(view.shownName, "Current")
}
}
private enum TestError: Error { case notFound }
```
## When to Prefer VIPER
Prefer VIPER when:
- multiple teams need independently owned feature modules with explicit boundaries
- strict role separation reduces architecture drift in long-lived codebases
- interactor-level business rules must be testable without booting UI screens
- modular compilation and clear dependency direction are high priorities
- UIKit-heavy codebase benefits from router-driven assembly/navigation
Prefer lighter patterns when:
- app is small or prototyping quickly
- ceremony cost outweighs boundary/testability benefits
Compared with organized MVVM, VIPER usually adds more setup but enforces role boundaries more strongly at scale, especially when teams and modules are decoupled.
## PR Review Checklist
- Component responsibilities are respected (View/Interactor/Presenter/Router separated).
- Presenter does not own business logic implementation details.
- Interactor does not navigate.
- Router handles only navigation and module assembly.
- Boundary protocols avoid concrete coupling.
- Retain cycles are prevented with weak references where needed.
- Tests cover presenter orchestration and interactor business rules.