
Swift Architecture Skill
- 1.5k installs
- 62 repo stars
- Updated August 4, 2026
- efremidze/swift-architecture-skill
swift-architecture-skill is an agent skill that swift architecture patterns and playbooks for mvvm, tca, clean architecture, and more.
About
swift-architecture-skill is an agent skill from efremidze/swift-architecture-skill that swift architecture patterns and playbooks for mvvm, tca, clean architecture, and more. # Swift Architecture Skill ## Overview Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the user’s task. For quick navigation across playbooks, use `references/_index.md`. ## Fast Path Before selecting an architecture, always capture: - task type (new feature, refactor, PR review, debuggin Developers invoke swift-architecture-skill during ship/testing work for testing & qa tasks. The skill documents triggers, prerequisites, and step-by-step workflows grounded in SKILL.md. Compatible with Claude Code, Cursor, and Codex agent runtimes that load marketplace skills. Review the Security Audits panel on this listing before installing in production environments. Category Testing & QA with development vertical focus supports repeatable agent-guided delivery.
- Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the user’s task.
- For quick navigation across playbooks, use `references/_index.md`.
- Before selecting an architecture, always capture:
- task type (new feature, refactor, PR review, debugging)
- UI stack (SwiftUI, UIKit, or mixed)
Swift Architecture Skill by the numbers
- 1,536 all-time installs (skills.sh)
- +57 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #475 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
swift-architecture-skill capabilities & compatibility
- Capabilities
- use this skill to pick the best swift architectu · for quick navigation across playbooks, use `refe · before selecting an architecture, always capture · task type (new feature, refactor, pr review, deb · ui stack (swiftui, uikit, or mixed)
- Use cases
- orchestration
What swift-architecture-skill says it does
Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the user’s task.
For quick navigation across playbooks, use `references/_index.md`.
Before selecting an architecture, always capture:
npx skills add https://github.com/efremidze/swift-architecture-skill --skill swift-architecture-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 62 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | efremidze/swift-architecture-skill ↗ |
What it does
Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
Who is it for?
Developers working on testing & qa during ship tasks.
Skip if: Tasks outside Testing & QA scope described in SKILL.md.
When should I use this skill?
Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
What you get
Completed testing & qa workflow aligned with SKILL.md steps.
- architecture recommendation
- implementation steps
- review checklists
Files
Swift Architecture Skill
Overview
Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the user’s task.
For quick navigation across playbooks, use references/_index.md.
Fast Path
Before selecting an architecture, always capture:
- task type (new feature, refactor, PR review, debugging)
- UI stack (SwiftUI, UIKit, or mixed)
- scope (single screen, multi-screen, app-wide)
- state and effect complexity
- team familiarity and dependency tolerance
- existing conventions to preserve
Then:
- if the user explicitly names an architecture, treat it as the initial candidate and run a fit check first
- if no architecture is named, load
references/selection-guide.mdand infer the best fit from the stated constraints - choose Quick Recommendation Mode for single-feature guidance with clear constraints
- choose Deep Refactor Mode for migrations, mixed architectures, or module boundary changes
Quick Recommendation Mode
Use this mode when:
- the scope is one feature or screen
- constraints are clear enough to recommend one primary pattern
- the user mainly needs a recommendation, scaffold, or review checklist
Deliver:
- fit result (
fitormismatch) - 1-2 reasons grounded in the request
- the selected reference file
- concrete structure, state, dependency, async, and testing guidance scoped to the feature
Deep Refactor Mode
Use this mode when:
- the request spans multiple modules or screens
- the codebase already mixes architectures
- the user is migrating from one pattern to another
Deliver:
- current-state assessment
- target architecture recommendation with fit or mismatch result
- incremental migration path with boundary changes called out
- risks, trade-offs, and verification points for the transition
Architecture Router
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
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
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
Guardrails
- Do not force an architecture switch for a small feature when the current local pattern is still a reasonable fit.
- Preserve existing conventions unless the mismatch is severe enough to justify change.
- Do not introduce new framework dependencies such as TCA unless the user explicitly accepts that trade-off or the codebase already uses them.
- Prefer the smallest architecture change that solves the request cleanly.
- Keep guidance architecture-specific; do not blend playbooks unless the boundary between patterns is explicit.
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
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 (
fitormismatch) 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.
Verification Checklist
Before finalizing:
1. confirm the selected pattern matches the user’s constraints and stack 2. confirm dependency injection, state ownership, effects, and testing strategy are covered 3. call out migration risk explicitly when recommending an architecture change 4. end with the selected architecture’s PR review checklist adapted to the user’s feature
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."
Reference Index
Quick navigation for the Swift Architecture skill.
Core Routing
| File | Use it for |
|---|---|
selection-guide.md | choosing the best-fit architecture from user constraints |
mvvm.md | low-to-medium complexity features with lightweight state binding |
mvi.md | reducer-style state machines without adding a framework dependency |
tca.md | complex, highly composable features with strict effect orchestration |
clean-architecture.md | strict layer boundaries and replaceable infrastructure |
viper.md | large UIKit modules needing explicit role separation |
reactive.md | Combine or RxSwift stream-heavy features and event pipelines |
mvp.md | UIKit-first passive views with presenter-driven rendering |
coordinator.md | decoupled navigation flows and deep-linkable screen orchestration |
Problem Router
- "I need help choosing an architecture" →
selection-guide.md - "The feature is simple and screen-scoped" →
mvvm.md - "I want deterministic state transitions without TCA" →
mvi.md - "The feature has complex state, child composition, and strict effects" →
tca.md - "I need use cases, repositories, and clean boundaries" →
clean-architecture.md - "This is a large UIKit module with clear presenter/interactor/router roles" →
viper.md - "The problem is stream-heavy or driven by Combine/RxSwift" →
reactive.md - "I want a passive UIKit view with a presenter" →
mvp.md - "The main issue is navigation flow and screen coordination" →
coordinator.md
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:
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
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.
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.
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.
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.
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.
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.
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 letfor 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/ObservableObjectViewModels 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
LiveRepositoryor 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
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.
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.
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
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.
@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.
@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:
@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.
@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:
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
Hashableenum soNavigationStackcan drive them - model sheets as an
Identifiableenum to bindsheet(item:) - mutate coordinator state on the main actor
- avoid deep conditional nesting in the
navigationDestinationclosure — preferswitch
Child Coordinator Pattern
Parent coordinators own child coordinators for nested flows.
@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.
@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:
ProfileViewControllercallsnavigationController?.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
childCoordinatorsbefore callingstart().
3. Navigation logic spread across ViewModels:
- Smell: ViewModel holds a reference to
AppCoordinatorand callscoordinator.showSettings()directly. - Fix: inject navigation closures (
onSettingsTapped: () -> Void) so the ViewModel stays decoupled from the coordinator type.
4. Deep linking bypasses coordinator:
- Smell:
AppDelegatecallsnavigationController.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.
@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
childCoordinatorsbeforestart()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.
MVI Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow and deterministic state transitions.
Mental Model
Intent -> Reducer -> New State -> View
-> Effect -> Action -> ReducerCore 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.
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.
enum CounterIntent {
case incrementTapped
case decrementTapped
case resetTapped
}Action
- Represent internal events and effect results.
- Reducer handles actions to complete async loops.
enum CounterAction {
case incrementResponse(Result<Int, Error>)
case decrementResponse(Result<Int, Error>)
case resetResponse(Result<Int, Error>)
}Action reducer for completing async transitions:
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.
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.
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.
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:
@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, executeEffect, dispatchAction. - Add cancellation and request versioning for concurrent requests.
- Map all expected service failures to explicit failure actions;
onUnexpectedErrorshould be a bug hook, not a business-error path.
@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.
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:
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.stateonly. - Send user events through
store.send(intent). - Never mutate domain state directly in views.
SwiftUI Integration
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.
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
Effectand handle through action loop.
2. Intent and action merged:
- Smell: one enum for both user input and effect output.
- Fix: separate
IntentandAction.
3. Multiple sources of truth:
- Smell: local
@Statemirrors store state. - Fix: keep canonical state in store only.
4. Derived fields stored redundantly:
- Smell: persisted
isEvenwithcount. - 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:
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.
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:
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
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.
@MainActor
protocol ProfileView: AnyObject {
func showLoading(_ isLoading: Bool)
func show(profile: ProfileViewData)
func showError(message: String)
}Rules:
- use
AnyObjectto 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.
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.
@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:
viewisweakto avoid retain cycles- cancel in-flight task before starting a new one
- gate state updates by
requestIDto prevent stale overwrites
UIKit View Implementation
The UIKit view controller forwards actions to Presenter and executes view commands.
@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.
@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.
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.viewafter 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
@Publishedproperties 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
UUIDper 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.
@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
iflogic, noguard, no formatting) - migrating from MVC and want a minimal step up without pulling in Combine or the
@Observablemacro - existing team is familiar with the Presenter + View protocol pattern
Prefer MVVM when:
- SwiftUI is the primary stack and
@Observable/@Publishedstate 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.
viewproperty in Presenter isweakand typed asProfileView.- 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.
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:
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.
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)
@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)
@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.
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.
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
ViewDatastructs for formatting and display concerns. - Keep View-local state only for transient UI details (focus, scroll position).
SwiftUI view with @Observable ViewModel (iOS 17+):
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):
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.
enum FeedDestination: Hashable {
case detail(id: UUID)
case profile(userId: UUID)
case settings
}Option A: ViewModel-owned path.
@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:
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.
@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.
@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
}
}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.
@MainActor
protocol FeedCoordinator: AnyObject {
func showDetail(itemID: UUID)
func showProfile(userId: UUID)
func presentCompose(onComplete: @MainActor @escaping () -> Void)
}Inject the Coordinator into the ViewModel:
@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:
@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.
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:
@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 itemsandviewModel.state.itemscoexist. - 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
UINavigationControllerusage 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.
// 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 throughawait MainActor.run.
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.
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.
Input -> Publisher/Observable chain -> State -> UIKeep stream composition in presentation or a dedicated reactive layer, not in views.
Canonical Combine Pattern
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/@Observabletypes, not inView. - 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.
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 matterswitchToLatest: keep only newest request (typeahead/search)share: avoid duplicate side effects for multiple subscriberscatch: recover from recoverable errors with fallback streams
Prefer switchToLatest over nested subscriptions for request replacement flows.
RxSwift Mapping Notes
Combine and RxSwift mapping:
AnyPublisher<->ObservableAnyCancellable<->DisposeBagreceive(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:
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
AnyCancellableor useDisposeBaglifecycle 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
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.
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 | Low–Med | High | High | Med–High | Med | Med | Low–Med | 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 | Medium–High | Medium–High | High | Low–Medium | Medium | Low–Medium |
| 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 | Medium–High | 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
StoreOfin views; UIKit uses a controller render loop fromViewStore. - 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
UINavigationControllerwrapper; SwiftUI models navigation as value-type state bound toNavigationStack.
Quick Decision Flow
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 architecturemismatch: 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 user’s feature.
TCA Playbook (Swift + SwiftUI/UIKit)
Use this reference for strict unidirectional flow, strong composition, and TestStore-driven testing.
Mental Model
View -> store.send(Action)
Reducer(State, Action) -> state mutation + Effect<Action>
Effect emits Action -> reducerCore 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.
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.
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)
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:
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.
@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
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
.ifLetreducer 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.
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
@Statemirrors 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.
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:
View -> Presenter -> Interactor -> Repository/Service -> Interactor -> Presenter -> View
Presenter -> Router (navigation)Canonical Feature Layout
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 (
@Observableon iOS 17+ orObservableObjectwhen 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.
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.
@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.
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
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):
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:
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
CancellationErrorexplicitly 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.
@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.
Related skills
FAQ
What does swift-architecture-skill do?
Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
When should I use swift-architecture-skill?
During ship testing work for testing & qa.
Is swift-architecture-skill safe to install?
Review the Security Audits panel on this listing before production use.