add agent skills and opencode config
This commit is contained in:
76
.agents/skills/swift-architecture-skill/SKILL.md
Normal file
76
.agents/skills/swift-architecture-skill/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: swift-architecture-skill
|
||||
description: Swift architecture patterns and playbooks for MVVM, TCA, Clean Architecture, and more.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Swift Architecture Skill
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to pick the best Swift architecture playbook for SwiftUI/UIKit codebases and apply it to the user’s task.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze the Request Context
|
||||
|
||||
Before selecting an architecture, capture:
|
||||
- task type (new feature, refactor, PR review, debugging)
|
||||
- UI stack (SwiftUI, UIKit, or mixed)
|
||||
- scope (single screen, multi-screen, app-wide)
|
||||
- existing conventions to preserve
|
||||
|
||||
### Step 2: Select the Architecture
|
||||
|
||||
If the user explicitly names an architecture, treat it as the initial candidate and run a fit check before committing:
|
||||
- validate against UI stack fit (SwiftUI/UIKit/mixed), state complexity, effect orchestration needs, team familiarity, and existing codebase conventions
|
||||
- if it fits, proceed with the requested architecture
|
||||
- if it mismatches key constraints, explicitly explain the mismatch and recommend the closest-fit alternative from `references/selection-guide.md`
|
||||
- if the user still insists on a mismatched architecture, proceed with a risk-mitigated plan and state the risks up front
|
||||
|
||||
When no architecture is named, load `references/selection-guide.md` and infer the best fit from stated constraints (state complexity, team familiarity, testing goals, effect orchestration needs, and framework preferences). Explain the recommendation briefly.
|
||||
|
||||
Architecture reference mapping:
|
||||
- MVVM → `references/mvvm.md`
|
||||
- MVI → `references/mvi.md`
|
||||
- TCA → `references/tca.md`
|
||||
- Clean Architecture → `references/clean-architecture.md`
|
||||
- VIPER → `references/viper.md`
|
||||
- Reactive → `references/reactive.md`
|
||||
- MVP → `references/mvp.md`
|
||||
- Coordinator → `references/coordinator.md`
|
||||
|
||||
### Step 3: Analyze Existing Codebase (When Applicable)
|
||||
|
||||
When code already exists:
|
||||
- detect current architecture and DI style
|
||||
- note concurrency model (async/await, Combine, GCD, mixed)
|
||||
- align recommendations to local conventions
|
||||
|
||||
### Step 4: Produce Concrete Deliverables
|
||||
|
||||
Read the selected architecture reference and convert its guidance into deliverables tailored to the user's request:
|
||||
|
||||
- **File and module structure**: directory layout with file names specific to the feature
|
||||
- **State and dependency boundaries**: concrete types, protocols, and injection points
|
||||
- **Async strategy**: cancellation, actor isolation, and error paths
|
||||
- **Testing strategy**: what to test, how to stub dependencies, and example test structure
|
||||
- **Migration path** (for refactors): incremental steps to move from current to target architecture
|
||||
- **UI stack adaptation**: where SwiftUI and UIKit guidance should differ for the chosen architecture
|
||||
|
||||
### Step 5: Validate with Checklist
|
||||
|
||||
End with the architecture-specific PR review checklist from the reference file, adapted to the user's feature.
|
||||
|
||||
## Output Requirements
|
||||
|
||||
- Keep recommendations scoped to the requested feature or review task.
|
||||
- Prefer protocol-based dependency injection and explicit state modeling.
|
||||
- Flag anti-patterns found in existing code and provide direct fixes.
|
||||
- Include cancellation and error handling in all async flows.
|
||||
- For explicit architecture requests, include a short fit result (`fit` or `mismatch`) with 1-2 reasons.
|
||||
- For mismatch cases, include one closest-fit alternative and why it better matches the stated constraints.
|
||||
- When writing code, include only the patterns relevant to the task — do not dump entire playbooks.
|
||||
- Treat reference snippets as illustrative by default; add full compile scaffolding only if the user asks for runnable code.
|
||||
- Ask only minimum blocking questions; otherwise proceed with explicit assumptions stated up front.
|
||||
- When reviewing PRs, use the architecture-specific checklist and call out specific violations with line-level fixes.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Swift Architecture Skill"
|
||||
short_description: "Architecture guidance for Swift, SwiftUI, and UIKit iOS projects"
|
||||
default_prompt: "Use this skill to select and apply the right architecture for Swift, SwiftUI, and UIKit features, then provide concrete implementation guidance and review checks."
|
||||
@@ -0,0 +1,322 @@
|
||||
# Clean Architecture Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference when a Swift codebase needs strict layer boundaries and use-case-driven business logic.
|
||||
|
||||
## Core Dependency Rule
|
||||
|
||||
Dependencies point inward:
|
||||
|
||||
```text
|
||||
Frameworks / UI
|
||||
->
|
||||
Interface Adapters
|
||||
->
|
||||
Use Cases
|
||||
->
|
||||
Entities (Domain)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- inner layers must not import or depend on outer layers
|
||||
- domain remains pure Swift
|
||||
- frameworks are implementation details and replaceable
|
||||
|
||||
## Canonical Layer Layout
|
||||
|
||||
```text
|
||||
Domain/
|
||||
Entities/
|
||||
UseCases/
|
||||
Data/
|
||||
Repositories/
|
||||
API/
|
||||
Persistence/
|
||||
Presentation/
|
||||
Features/
|
||||
App/
|
||||
```
|
||||
|
||||
Guidance:
|
||||
- keep entities and use-case protocols in `Domain`
|
||||
- keep repository implementations and external adapters in `Data`
|
||||
- keep views/view models/controllers in `Presentation`
|
||||
- keep DI composition root and app bootstrap in `App`
|
||||
|
||||
## Entities
|
||||
|
||||
Entities model core business concepts and rules.
|
||||
|
||||
```swift
|
||||
struct User: Equatable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- no SwiftUI/UIKit imports
|
||||
- no persistence or network behavior
|
||||
- avoid framework-specific types unless unavoidable
|
||||
|
||||
## Use Cases
|
||||
|
||||
Use cases orchestrate business actions through abstractions.
|
||||
|
||||
```swift
|
||||
protocol LoadUserUseCase {
|
||||
func execute(id: UUID) async throws -> User
|
||||
}
|
||||
|
||||
final class LoadUser: LoadUserUseCase {
|
||||
private let repository: UserRepository
|
||||
|
||||
init(repository: UserRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(id: UUID) async throws -> User {
|
||||
try await repository.fetch(id: id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- one business responsibility per use case
|
||||
- no UI details
|
||||
- no direct framework usage unless abstracted
|
||||
|
||||
## Repository Boundary
|
||||
|
||||
Define repository protocols in `Domain`; implement them in `Data`.
|
||||
|
||||
```swift
|
||||
protocol UserRepository {
|
||||
func fetch(id: UUID) async throws -> User
|
||||
}
|
||||
```
|
||||
|
||||
Data-layer implementations can coordinate:
|
||||
- API clients
|
||||
- local persistence
|
||||
- mapping DTOs to domain entities
|
||||
|
||||
## Dependency Injection Pattern
|
||||
|
||||
Compose live dependencies in the app or feature assembly layer.
|
||||
|
||||
```swift
|
||||
enum UserFeatureAssembly {
|
||||
static func makeLoadUserUseCase() -> LoadUserUseCase {
|
||||
let repository = LiveUserRepository(api: .live)
|
||||
return LoadUser(repository: repository)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- inject protocols into use cases and presentation
|
||||
- avoid global singletons as hidden dependencies
|
||||
|
||||
## DTO to Domain Mapping
|
||||
|
||||
Map external models to domain entities at the data-layer boundary, in mappers or repository implementations.
|
||||
|
||||
```swift
|
||||
struct UserDTO: Decodable {
|
||||
let id: String
|
||||
let full_name: String
|
||||
let created_at: String
|
||||
}
|
||||
|
||||
enum UserMapper {
|
||||
static func toDomain(_ dto: UserDTO) throws -> User {
|
||||
guard let id = UUID(uuidString: dto.id) else {
|
||||
throw MappingError.invalidID(dto.id)
|
||||
}
|
||||
return User(id: id, name: dto.full_name)
|
||||
}
|
||||
}
|
||||
|
||||
enum MappingError: Error {
|
||||
case invalidID(String)
|
||||
}
|
||||
|
||||
final class LiveUserRepository: UserRepository {
|
||||
private let api: APIClient
|
||||
|
||||
init(api: APIClient) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func fetch(id: UUID) async throws -> User {
|
||||
let dto = try await api.fetchUser(id: id)
|
||||
return try UserMapper.toDomain(dto)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- never expose DTOs beyond the data layer
|
||||
- test mappers independently for edge cases and invalid input
|
||||
- keep mapping pure and side-effect-free
|
||||
|
||||
## Concurrency and Cancellation
|
||||
|
||||
Use structured concurrency in use cases and let cancellation propagate through async calls.
|
||||
|
||||
```swift
|
||||
final class LoadUserProfile: LoadUserProfileUseCase {
|
||||
private let userRepo: UserRepository
|
||||
private let postsRepo: PostsRepository
|
||||
|
||||
init(userRepo: UserRepository, postsRepo: PostsRepository) {
|
||||
self.userRepo = userRepo
|
||||
self.postsRepo = postsRepo
|
||||
}
|
||||
|
||||
func execute(id: UUID) async throws -> UserProfile {
|
||||
async let user = userRepo.fetch(id: id)
|
||||
async let posts = postsRepo.fetchRecent(userID: id)
|
||||
return try await UserProfile(user: user, posts: posts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- prefer `async let` for concurrent independent fetches
|
||||
- cancellation propagates automatically through `try await`
|
||||
- use `Task.checkCancellation()` before expensive work if needed
|
||||
- in presentation, cancel tasks on view disappearance or new request
|
||||
|
||||
## Presentation Boundary
|
||||
|
||||
Presentation depends on use-case abstractions, not data implementations.
|
||||
|
||||
Expected flow:
|
||||
- View triggers intent/event
|
||||
- Presentation layer calls `UseCase`
|
||||
- UseCase returns domain entities
|
||||
- Presentation maps entities to view state
|
||||
|
||||
SwiftUI adaptation:
|
||||
- use `@Observable`/`ObservableObject` ViewModels that expose view state
|
||||
- trigger use cases from intent methods on the ViewModel
|
||||
- keep SwiftUI views declarative and free of use-case/repository calls
|
||||
|
||||
UIKit adaptation:
|
||||
- use Presenter/ViewModel objects owned by view controllers
|
||||
- convert delegate/target-action events into presenter intents
|
||||
- keep controllers responsible for rendering only; business coordination stays in presenter/use case layers
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. God use case:
|
||||
- Smell: single 500+ line use case handling many responsibilities.
|
||||
- Fix: split by business capability and compose use cases.
|
||||
|
||||
2. Presentation imports data layer:
|
||||
- Smell: feature view model directly uses `LiveRepository` or API client.
|
||||
- Fix: depend on use-case protocol only.
|
||||
|
||||
3. Domain depends on frameworks:
|
||||
- Smell: domain entities use UI/network/persistence frameworks.
|
||||
- Fix: keep domain pure and move adapters outward.
|
||||
|
||||
4. Repository leaks transport types:
|
||||
- Smell: presentation receives DTO/network models.
|
||||
- Fix: map external models to domain entities in data layer.
|
||||
|
||||
5. Testing through real infrastructure:
|
||||
- Smell: unit tests require network/db.
|
||||
- Fix: test use cases with mocked/stub repositories.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Prioritize:
|
||||
- use-case unit tests with repository stubs
|
||||
- mapper tests (DTO <-> domain) in data layer
|
||||
- presentation tests with mocked use cases
|
||||
|
||||
Rules:
|
||||
- avoid network in unit tests
|
||||
- assert business behavior at use-case boundary
|
||||
- keep async tests deterministic using controlled stubs
|
||||
- test cancellation propagation for long-running use cases
|
||||
|
||||
```swift
|
||||
struct StubUserRepository: UserRepository {
|
||||
var result: Result<User, Error>
|
||||
|
||||
func fetch(id: UUID) async throws -> User {
|
||||
try result.get()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LoadUserTests: XCTestCase {
|
||||
func test_execute_returnsUser() async throws {
|
||||
let expected = User(id: UUID(), name: "Alice")
|
||||
let sut = LoadUser(repository: StubUserRepository(result: .success(expected)))
|
||||
let user = try await sut.execute(id: expected.id)
|
||||
XCTAssertEqual(user, expected)
|
||||
}
|
||||
|
||||
func test_execute_propagatesFailure() async {
|
||||
let sut = LoadUser(repository: StubUserRepository(result: .failure(TestError.notFound)))
|
||||
do {
|
||||
_ = try await sut.execute(id: UUID())
|
||||
XCTFail("Expected error")
|
||||
} catch {
|
||||
XCTAssertTrue(error is TestError)
|
||||
}
|
||||
}
|
||||
|
||||
func test_execute_cancellationPropagates() async {
|
||||
let sut = LoadUser(repository: BlockingUserRepository())
|
||||
// Deterministic because this test class is @MainActor:
|
||||
// Task { ... } inherits main-actor isolation and does not start executing
|
||||
// until the main actor yields at await task.value, so cancellation is
|
||||
// observed immediately. Without @MainActor this pattern is racy.
|
||||
let task = Task { try await sut.execute(id: UUID()) }
|
||||
task.cancel()
|
||||
|
||||
do {
|
||||
_ = try await task.value
|
||||
XCTFail("Expected cancellation")
|
||||
} catch is CancellationError {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor BlockingUserRepository: UserRepository {
|
||||
func fetch(id: UUID) async throws -> User {
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return User(id: id, name: "")
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error { case notFound }
|
||||
```
|
||||
|
||||
## When to Prefer Clean Architecture
|
||||
|
||||
Prefer when:
|
||||
- app/domain complexity is medium to large
|
||||
- multiple teams need stable boundaries
|
||||
- long-term maintainability and replaceable infrastructure matter
|
||||
|
||||
Prefer lighter layering when:
|
||||
- app is small and short-lived
|
||||
- strict layering overhead is higher than expected benefit
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- Dependency direction points inward only.
|
||||
- Domain layer is framework-independent.
|
||||
- Use cases encapsulate business rules and stay focused.
|
||||
- Presentation does not import data implementations.
|
||||
- Repository abstractions live at domain boundary.
|
||||
- Tests isolate use cases from infrastructure.
|
||||
@@ -0,0 +1,466 @@
|
||||
# Coordinator Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference when navigation logic needs to be decoupled from individual screens, enabling reusable flows, deep linking, and testable routing without view controllers owning their own transitions.
|
||||
|
||||
## Core Concept
|
||||
|
||||
A Coordinator owns one navigation flow. It creates and connects screens, passes dependencies, and decides what happens next when a user action triggers a transition.
|
||||
|
||||
```text
|
||||
AppCoordinator
|
||||
-> AuthCoordinator (owns login/signup flow)
|
||||
-> MainCoordinator (owns tab/home flow)
|
||||
-> ProfileCoordinator (owns profile flow)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- each coordinator owns one flow (a screen, a sub-flow, or a full section)
|
||||
- screens emit navigation events; coordinators decide what to do with them
|
||||
- screens do not reference coordinators or push/present directly
|
||||
- parent coordinators launch child coordinators for nested flows
|
||||
|
||||
## Feature Structure
|
||||
|
||||
```text
|
||||
App/
|
||||
AppCoordinator.swift
|
||||
Coordinators/
|
||||
AuthCoordinator.swift
|
||||
MainCoordinator.swift
|
||||
ProfileCoordinator.swift
|
||||
Features/
|
||||
Auth/
|
||||
LoginViewModel.swift
|
||||
LoginView.swift
|
||||
Profile/
|
||||
ProfileViewModel.swift
|
||||
ProfileView.swift
|
||||
Navigation/
|
||||
Coordinator.swift (protocol)
|
||||
NavigationRouter.swift (UIKit helper)
|
||||
```
|
||||
|
||||
## Coordinator Protocol
|
||||
|
||||
Define a minimal base contract.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol Coordinator: AnyObject {
|
||||
var childCoordinators: [Coordinator] { get set }
|
||||
func start()
|
||||
}
|
||||
|
||||
extension Coordinator {
|
||||
func addChild(_ coordinator: Coordinator) {
|
||||
childCoordinators.append(coordinator)
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
func removeChild(_ coordinator: Coordinator) {
|
||||
childCoordinators.removeAll { $0 === coordinator }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- retain child coordinators so they are not deallocated mid-flow
|
||||
- remove child coordinators when the flow they own completes
|
||||
- `start()` is the single entry point that kicks off the flow
|
||||
|
||||
## UIKit Coordinator
|
||||
|
||||
For UIKit, wrap a `UINavigationController` in a thin router.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class NavigationRouter {
|
||||
let navigationController: UINavigationController
|
||||
|
||||
init(navigationController: UINavigationController = UINavigationController()) {
|
||||
self.navigationController = navigationController
|
||||
}
|
||||
|
||||
func push(_ viewController: UIViewController, animated: Bool = true) {
|
||||
navigationController.pushViewController(viewController, animated: animated)
|
||||
}
|
||||
|
||||
func present(_ viewController: UIViewController, animated: Bool = true) {
|
||||
navigationController.present(viewController, animated: animated)
|
||||
}
|
||||
|
||||
func pop(animated: Bool = true) {
|
||||
navigationController.popViewController(animated: animated)
|
||||
}
|
||||
|
||||
func popToRoot(animated: Bool = true) {
|
||||
navigationController.popToRootViewController(animated: animated)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile flow coordinator example:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class ProfileCoordinator: Coordinator {
|
||||
var childCoordinators: [Coordinator] = []
|
||||
private let router: NavigationRouter
|
||||
private let userRepository: UserRepository
|
||||
|
||||
init(router: NavigationRouter, userRepository: UserRepository) {
|
||||
self.router = router
|
||||
self.userRepository = userRepository
|
||||
}
|
||||
|
||||
func start() {
|
||||
let viewModel = ProfileViewModel(
|
||||
repository: userRepository,
|
||||
onEditTapped: { [weak self] in self?.showEditProfile() },
|
||||
onLogoutTapped: { [weak self] in self?.finish() }
|
||||
)
|
||||
let viewController = ProfileViewController(viewModel: viewModel)
|
||||
router.push(viewController)
|
||||
}
|
||||
|
||||
private func showEditProfile() {
|
||||
let editCoordinator = EditProfileCoordinator(
|
||||
router: router,
|
||||
userRepository: userRepository,
|
||||
onComplete: { [weak self] in self?.removeChild($0) }
|
||||
)
|
||||
addChild(editCoordinator)
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
// Notify parent this flow is done.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SwiftUI Coordinator
|
||||
|
||||
For SwiftUI, model navigation state as a value type and bind it to `NavigationStack`.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppCoordinator: Coordinator {
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var path: [AppDestination] = []
|
||||
var sheet: AppSheet?
|
||||
|
||||
private let userRepository: UserRepository
|
||||
|
||||
init(userRepository: UserRepository) {
|
||||
self.userRepository = userRepository
|
||||
}
|
||||
|
||||
func start() {
|
||||
// Nothing to push — root is set at view layer.
|
||||
}
|
||||
|
||||
func showProfile(userID: UUID) {
|
||||
path.append(.profile(userID))
|
||||
}
|
||||
|
||||
func showSettings() {
|
||||
sheet = .settings
|
||||
}
|
||||
|
||||
func pop() {
|
||||
guard !path.isEmpty else { return }
|
||||
path.removeLast()
|
||||
}
|
||||
|
||||
func dismissSheet() {
|
||||
sheet = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum AppDestination: Hashable {
|
||||
case profile(UUID)
|
||||
case editProfile(UUID)
|
||||
}
|
||||
|
||||
enum AppSheet: Identifiable {
|
||||
case settings
|
||||
var id: String { "\(self)" }
|
||||
}
|
||||
```
|
||||
|
||||
Root view binds coordinator state to `NavigationStack`:
|
||||
|
||||
```swift
|
||||
struct AppRootView: View {
|
||||
@State private var coordinator: AppCoordinator
|
||||
|
||||
init(coordinator: AppCoordinator) {
|
||||
self._coordinator = State(initialValue: coordinator)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var coordinator = coordinator
|
||||
|
||||
NavigationStack(path: $coordinator.path) {
|
||||
HomeView(
|
||||
onProfileTapped: { id in coordinator.showProfile(userID: id) },
|
||||
onSettingsTapped: { coordinator.showSettings() }
|
||||
)
|
||||
.navigationDestination(for: AppDestination.self) { destination in
|
||||
switch destination {
|
||||
case .profile(let id):
|
||||
ProfileView(viewModel: makeProfileViewModel(userID: id))
|
||||
case .editProfile(let id):
|
||||
EditProfileView(userID: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $coordinator.sheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
SettingsView(onDismiss: { coordinator.dismissSheet() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeProfileViewModel(userID: UUID) -> ProfileViewModel {
|
||||
ProfileViewModel(
|
||||
userID: userID,
|
||||
repository: coordinator.userRepository,
|
||||
onEditTapped: { coordinator.path.append(.editProfile(userID)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- model destinations as a `Hashable` enum so `NavigationStack` can drive them
|
||||
- model sheets as an `Identifiable` enum to bind `sheet(item:)`
|
||||
- mutate coordinator state on the main actor
|
||||
- avoid deep conditional nesting in the `navigationDestination` closure — prefer `switch`
|
||||
|
||||
## Child Coordinator Pattern
|
||||
|
||||
Parent coordinators own child coordinators for nested flows.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class MainCoordinator: Coordinator {
|
||||
var childCoordinators: [Coordinator] = []
|
||||
private let router: NavigationRouter
|
||||
private let userRepository: UserRepository
|
||||
|
||||
init(router: NavigationRouter, userRepository: UserRepository) {
|
||||
self.router = router
|
||||
self.userRepository = userRepository
|
||||
}
|
||||
|
||||
func start() {
|
||||
showHome()
|
||||
}
|
||||
|
||||
func showHome() {
|
||||
let viewModel = HomeViewModel(
|
||||
onProfileTapped: { [weak self] id in self?.showProfile(userID: id) }
|
||||
)
|
||||
let viewController = HomeViewController(viewModel: viewModel)
|
||||
router.push(viewController)
|
||||
}
|
||||
|
||||
private func showProfile(userID: UUID) {
|
||||
let profileRouter = NavigationRouter(
|
||||
navigationController: router.navigationController
|
||||
)
|
||||
let coordinator = ProfileCoordinator(
|
||||
router: profileRouter,
|
||||
userRepository: userRepository
|
||||
)
|
||||
addChild(coordinator)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
Handle deep links by parsing a URL into a destination and routing directly to it.
|
||||
Push destinations update `path`; sheet destinations set `sheet`.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class DeepLinkHandler {
|
||||
private let coordinator: AppCoordinator
|
||||
|
||||
init(coordinator: AppCoordinator) {
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
func handle(url: URL) {
|
||||
guard url.scheme == "myapp" else { return }
|
||||
switch url.host {
|
||||
case "profile":
|
||||
guard
|
||||
let idString = url.pathComponents.dropFirst().first,
|
||||
let id = UUID(uuidString: idString)
|
||||
else { return }
|
||||
coordinator.path = [.profile(id)]
|
||||
case "settings":
|
||||
coordinator.sheet = .settings
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. View controller pushes its own next screen:
|
||||
- Smell: `ProfileViewController` calls `navigationController?.pushViewController(SettingsViewController(), animated: true)` directly.
|
||||
- Fix: emit a closure or delegate event; let the Coordinator perform the push.
|
||||
|
||||
2. Coordinator retained only by a local variable:
|
||||
- Smell: parent loses reference to child coordinator; it deallocates mid-flow.
|
||||
- Fix: add child to `childCoordinators` before calling `start()`.
|
||||
|
||||
3. Navigation logic spread across ViewModels:
|
||||
- Smell: ViewModel holds a reference to `AppCoordinator` and calls `coordinator.showSettings()` directly.
|
||||
- Fix: inject navigation closures (`onSettingsTapped: () -> Void`) so the ViewModel stays decoupled from the coordinator type.
|
||||
|
||||
4. Deep linking bypasses coordinator:
|
||||
- Smell: `AppDelegate` calls `navigationController.pushViewController(...)` directly on deep link receipt.
|
||||
- Fix: route all deep links through `DeepLinkHandler` → `AppCoordinator.handle(url:)`.
|
||||
|
||||
5. Coordinator mixing business logic:
|
||||
- Smell: Coordinator fetches data or applies business rules before routing.
|
||||
- Fix: keep Coordinator responsible only for navigation; delegate data work to ViewModels/Repositories.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Test Coordinators by verifying navigation state changes for success paths (expected destinations appended), failure paths (unknown inputs handled without crashing), and cancellation-safe pop operations.
|
||||
Use stub repositories and direct coordinator state inspection to keep tests deterministic.
|
||||
Avoid sleeps; prefer synchronous state mutations and direct property assertions.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class SpyNavigationRouter: NavigationRouter {
|
||||
var pushedViewControllers: [UIViewController] = []
|
||||
var presentedViewControllers: [UIViewController] = []
|
||||
|
||||
override func push(_ viewController: UIViewController, animated: Bool = true) {
|
||||
pushedViewControllers.append(viewController)
|
||||
}
|
||||
|
||||
override func present(_ viewController: UIViewController, animated: Bool = true) {
|
||||
presentedViewControllers.append(viewController)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProfileCoordinatorTests: XCTestCase {
|
||||
func test_start_pushesProfileViewController() {
|
||||
let router = SpyNavigationRouter()
|
||||
let coordinator = ProfileCoordinator(
|
||||
router: router,
|
||||
userRepository: StubUserRepository()
|
||||
)
|
||||
|
||||
coordinator.start()
|
||||
|
||||
XCTAssertEqual(router.pushedViewControllers.count, 1)
|
||||
XCTAssertTrue(router.pushedViewControllers.first is ProfileViewController)
|
||||
}
|
||||
|
||||
func test_showEditProfile_addsChildCoordinator() {
|
||||
let router = SpyNavigationRouter()
|
||||
let coordinator = ProfileCoordinator(
|
||||
router: router,
|
||||
userRepository: StubUserRepository()
|
||||
)
|
||||
coordinator.start()
|
||||
|
||||
coordinator.showEditProfileForTesting()
|
||||
|
||||
XCTAssertEqual(coordinator.childCoordinators.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppCoordinatorTests: XCTestCase {
|
||||
func test_showProfile_success_appendsDestination() {
|
||||
let coordinator = AppCoordinator(userRepository: StubUserRepository())
|
||||
let id = UUID()
|
||||
|
||||
coordinator.showProfile(userID: id)
|
||||
|
||||
XCTAssertEqual(coordinator.path, [.profile(id)])
|
||||
}
|
||||
|
||||
func test_pop_removesLastDestination() {
|
||||
let coordinator = AppCoordinator(userRepository: StubUserRepository())
|
||||
coordinator.path = [.profile(UUID()), .editProfile(UUID())]
|
||||
|
||||
coordinator.pop()
|
||||
|
||||
XCTAssertEqual(coordinator.path.count, 1)
|
||||
}
|
||||
|
||||
func test_dismissSheet_clearsSheet() {
|
||||
let coordinator = AppCoordinator(userRepository: StubUserRepository())
|
||||
coordinator.sheet = .settings
|
||||
|
||||
coordinator.dismissSheet()
|
||||
|
||||
XCTAssertNil(coordinator.sheet)
|
||||
}
|
||||
|
||||
func test_deepLink_failure_doesNotCrashOnUnknownScheme() {
|
||||
let coordinator = AppCoordinator(userRepository: StubUserRepository())
|
||||
let handler = DeepLinkHandler(coordinator: coordinator)
|
||||
let unknownURL = URL(string: "https://example.com/profile/123")!
|
||||
|
||||
handler.handle(url: unknownURL)
|
||||
|
||||
XCTAssertTrue(coordinator.path.isEmpty)
|
||||
}
|
||||
|
||||
func test_pop_cancellation_onEmptyPath_doesNotCrash() {
|
||||
let coordinator = AppCoordinator(userRepository: StubUserRepository())
|
||||
XCTAssertTrue(coordinator.path.isEmpty)
|
||||
|
||||
coordinator.pop()
|
||||
|
||||
XCTAssertTrue(coordinator.path.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
struct StubUserRepository: UserRepository {
|
||||
func fetchCurrentUser() async throws -> User {
|
||||
User(id: UUID(), name: "Stub", isPremium: false, joinDate: .now)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `showEditProfileForTesting()` exposes the private routing action for test access — annotate with `#if DEBUG` or use `@testable import` and `internal` access level to keep production code clean.
|
||||
|
||||
## When to Prefer Coordinator
|
||||
|
||||
Prefer Coordinator when:
|
||||
- navigation logic is complex (conditional flows, deep linking, multi-step wizards)
|
||||
- multiple screens need to be reused across different flows
|
||||
- you want to test routing logic without instantiating full screens
|
||||
- ViewModels and View Controllers should have zero navigation coupling
|
||||
|
||||
Pair with MVVM by injecting navigation closures into ViewModels; pair with MVP by having the Presenter call a Router protocol backed by a Coordinator.
|
||||
|
||||
The Coordinator pattern is not an architecture on its own — it is a navigation layer that complements presentation patterns. Prefer it when `UINavigationController` push/present calls scattered across view controllers make flows hard to follow or test.
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- Each coordinator owns one clearly scoped flow.
|
||||
- Child coordinators are retained in `childCoordinators` before `start()` is called.
|
||||
- Child coordinators are removed when their flow completes.
|
||||
- ViewModels and View Controllers receive navigation closures, not coordinator references.
|
||||
- Navigation state (SwiftUI path/sheet) is modeled as value types.
|
||||
- Deep link handling routes through the coordinator, not directly to view controllers.
|
||||
- Tests verify routing state changes without relying on UIKit presentation timing.
|
||||
570
.agents/skills/swift-architecture-skill/references/mvi.md
Normal file
570
.agents/skills/swift-architecture-skill/references/mvi.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# MVI Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference for strict unidirectional flow and deterministic state transitions.
|
||||
|
||||
## Mental Model
|
||||
|
||||
```text
|
||||
Intent -> Reducer -> New State -> View
|
||||
-> Effect -> Action -> Reducer
|
||||
```
|
||||
|
||||
Core rules:
|
||||
- Keep one source of truth: `State`.
|
||||
- Keep reducer logic deterministic.
|
||||
- Isolate side effects in `Effect`.
|
||||
- Feed effect output back as `Action`.
|
||||
|
||||
## Core Types
|
||||
|
||||
### State
|
||||
|
||||
- Use value types (`struct`) only.
|
||||
- Keep state equatable/serializable where practical.
|
||||
- Store canonical state, not redundant derived values.
|
||||
|
||||
```swift
|
||||
enum Loadable<Value: Equatable>: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
struct CounterState: Equatable {
|
||||
var load: Loadable<Int> = .idle
|
||||
|
||||
var count: Int {
|
||||
guard case .loaded(let value) = load else { return 0 }
|
||||
return value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Intent
|
||||
|
||||
- Represent user-driven input only.
|
||||
- Do not use intents for network responses.
|
||||
|
||||
```swift
|
||||
enum CounterIntent {
|
||||
case incrementTapped
|
||||
case decrementTapped
|
||||
case resetTapped
|
||||
}
|
||||
```
|
||||
|
||||
### Action
|
||||
|
||||
- Represent internal events and effect results.
|
||||
- Reducer handles actions to complete async loops.
|
||||
|
||||
```swift
|
||||
enum CounterAction {
|
||||
case incrementResponse(Result<Int, Error>)
|
||||
case decrementResponse(Result<Int, Error>)
|
||||
case resetResponse(Result<Int, Error>)
|
||||
}
|
||||
```
|
||||
|
||||
Action reducer for completing async transitions:
|
||||
|
||||
```swift
|
||||
func reduce(state: inout CounterState, action: CounterAction) {
|
||||
switch action {
|
||||
case .incrementResponse(.success(let value)):
|
||||
state.load = .loaded(value)
|
||||
case .incrementResponse(.failure(let error)):
|
||||
state.load = .failed(error.localizedDescription)
|
||||
case .decrementResponse(.success(let value)):
|
||||
state.load = .loaded(value)
|
||||
case .decrementResponse(.failure(let error)):
|
||||
state.load = .failed(error.localizedDescription)
|
||||
case .resetResponse(.success(let value)):
|
||||
state.load = .loaded(value)
|
||||
case .resetResponse(.failure(let error)):
|
||||
state.load = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Effect
|
||||
|
||||
- Encapsulate async side effects.
|
||||
- Keep effect execution in the store.
|
||||
|
||||
```swift
|
||||
enum Effect<Action> {
|
||||
case none
|
||||
case run(() async throws -> Action)
|
||||
case cancellable(id: AnyHashable, () async throws -> Action)
|
||||
}
|
||||
```
|
||||
|
||||
## Reducer Pattern
|
||||
|
||||
- Reducer over `Intent`: mutate state for immediate transitions and optionally return effect.
|
||||
- Reducer over `Action`: finish transition from effect output.
|
||||
- Avoid direct side effects inside reducer branches.
|
||||
|
||||
```swift
|
||||
protocol CounterServicing {
|
||||
func increment() async throws -> Int
|
||||
func decrement() async throws -> Int
|
||||
func reset() async throws -> Int
|
||||
}
|
||||
|
||||
func reduce(
|
||||
state: inout CounterState,
|
||||
intent: CounterIntent,
|
||||
service: CounterServicing
|
||||
) -> Effect<CounterAction>? {
|
||||
switch intent {
|
||||
case .incrementTapped:
|
||||
state.load = .loading
|
||||
return .run {
|
||||
do {
|
||||
let value = try await service.increment()
|
||||
return .incrementResponse(.success(value))
|
||||
} catch {
|
||||
return .incrementResponse(.failure(error))
|
||||
}
|
||||
}
|
||||
case .decrementTapped:
|
||||
state.load = .loading
|
||||
return .run {
|
||||
do {
|
||||
let value = try await service.decrement()
|
||||
return .decrementResponse(.success(value))
|
||||
} catch {
|
||||
return .decrementResponse(.failure(error))
|
||||
}
|
||||
}
|
||||
case .resetTapped:
|
||||
state.load = .loading
|
||||
return .run {
|
||||
do {
|
||||
let value = try await service.reset()
|
||||
return .resetResponse(.success(value))
|
||||
} catch {
|
||||
return .resetResponse(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This signature is a pragmatic shortcut: passing `service` into `reduce` keeps call sites simple, but the reducer is environment-coupled. If you want stricter MVI purity, make `reduce` return effect descriptors and run them outside the reducer.
|
||||
|
||||
```swift
|
||||
enum CounterEffect {
|
||||
case increment
|
||||
case decrement
|
||||
case reset
|
||||
}
|
||||
|
||||
func reduce(state: inout CounterState, intent: CounterIntent) -> CounterEffect? {
|
||||
switch intent {
|
||||
case .incrementTapped:
|
||||
state.load = .loading
|
||||
return .increment
|
||||
case .decrementTapped:
|
||||
state.load = .loading
|
||||
return .decrement
|
||||
case .resetTapped:
|
||||
state.load = .loading
|
||||
return .reset
|
||||
}
|
||||
}
|
||||
|
||||
func run(_ effect: CounterEffect, service: CounterServicing) async -> CounterAction {
|
||||
do {
|
||||
switch effect {
|
||||
case .increment:
|
||||
return .incrementResponse(.success(try await service.increment()))
|
||||
case .decrement:
|
||||
return .decrementResponse(.success(try await service.decrement()))
|
||||
case .reset:
|
||||
return .resetResponse(.success(try await service.reset()))
|
||||
}
|
||||
} catch {
|
||||
switch effect {
|
||||
case .increment:
|
||||
return .incrementResponse(.failure(error))
|
||||
case .decrement:
|
||||
return .decrementResponse(.failure(error))
|
||||
case .reset:
|
||||
return .resetResponse(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adapter pattern for wiring the pure `reduce/run` pair into `Store`:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
func makeCounterStore(service: CounterServicing) -> Store<CounterState, CounterIntent, CounterAction> {
|
||||
Store(
|
||||
initial: CounterState(),
|
||||
reduceIntent: { state, intent in
|
||||
guard let effect = reduce(state: &state, intent: intent) else { return nil }
|
||||
return .run {
|
||||
await run(effect, service: service)
|
||||
}
|
||||
},
|
||||
reduceAction: { state, action in
|
||||
reduce(state: &state, action: action)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Store Pattern
|
||||
|
||||
- Keep store on main actor for UI mutation safety.
|
||||
- Receive `Intent`, run reducer, execute `Effect`, dispatch `Action`.
|
||||
- Add cancellation and request versioning for concurrent requests.
|
||||
- Map all expected service failures to explicit failure actions; `onUnexpectedError` should be a bug hook, not a business-error path.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class Store<State, Intent, Action>: ObservableObject {
|
||||
@Published private(set) var state: State
|
||||
|
||||
private let reduceIntent: (inout State, Intent) -> Effect<Action>?
|
||||
private let reduceAction: (inout State, Action) -> Void
|
||||
private let onUnexpectedError: @MainActor (Error) -> Void
|
||||
private var activeTasks: [AnyHashable: Task<Void, Never>] = [:]
|
||||
|
||||
init(
|
||||
initial: State,
|
||||
reduceIntent: @escaping (inout State, Intent) -> Effect<Action>?,
|
||||
reduceAction: @escaping (inout State, Action) -> Void,
|
||||
onUnexpectedError: @escaping @MainActor (Error) -> Void = { error in
|
||||
assertionFailure("Unexpected unmodeled effect error: \(error)")
|
||||
}
|
||||
) {
|
||||
self.state = initial
|
||||
self.reduceIntent = reduceIntent
|
||||
self.reduceAction = reduceAction
|
||||
self.onUnexpectedError = onUnexpectedError
|
||||
}
|
||||
|
||||
func send(_ intent: Intent) {
|
||||
guard let effect = reduceIntent(&state, intent) else { return }
|
||||
handle(effect)
|
||||
}
|
||||
|
||||
private func handle(_ effect: Effect<Action>) {
|
||||
switch effect {
|
||||
case .none:
|
||||
break
|
||||
case .run(let operation):
|
||||
Task {
|
||||
do {
|
||||
let action = try await operation()
|
||||
reduceAction(&state, action)
|
||||
} catch is CancellationError {
|
||||
// Task was cancelled; no state update.
|
||||
} catch {
|
||||
onUnexpectedError(error)
|
||||
}
|
||||
}
|
||||
case .cancellable(let id, let operation):
|
||||
activeTasks[id]?.cancel()
|
||||
activeTasks[id] = Task {
|
||||
do {
|
||||
let action = try await operation()
|
||||
reduceAction(&state, action)
|
||||
} catch is CancellationError {
|
||||
// Cancelled by a newer request for the same id.
|
||||
} catch {
|
||||
onUnexpectedError(error)
|
||||
}
|
||||
activeTasks[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
for task in activeTasks.values { task.cancel() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Map expected service failures to explicit failure actions; reserve `onUnexpectedError` for true fallthrough faults (for example decoding bugs, violated invariants, or effect wiring mistakes). If this handler fires for normal API failures, treat that as a modeling bug and add an explicit failure action path.
|
||||
|
||||
## Composed Reducers
|
||||
|
||||
Split reducers by feature and compose them.
|
||||
|
||||
```swift
|
||||
enum AppAction {
|
||||
case counter(CounterAction)
|
||||
case settings(SettingsAction)
|
||||
}
|
||||
|
||||
func appReduce(
|
||||
state: inout AppState,
|
||||
intent: AppIntent,
|
||||
services: AppServices
|
||||
) -> Effect<AppAction>? {
|
||||
switch intent {
|
||||
case .counter(let counterIntent):
|
||||
return counterReduce(
|
||||
state: &state.counter,
|
||||
intent: counterIntent,
|
||||
service: services.counter
|
||||
)?.map(AppAction.counter)
|
||||
case .settings(let settingsIntent):
|
||||
return settingsReduce(
|
||||
state: &state.settings,
|
||||
intent: settingsIntent,
|
||||
service: services.settings
|
||||
)?.map(AppAction.settings)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add a `map` helper on `Effect` to lift child actions into parent actions:
|
||||
|
||||
```swift
|
||||
extension Effect {
|
||||
func map<B>(_ transform: @escaping (Action) -> B) -> Effect<B> {
|
||||
switch self {
|
||||
case .none:
|
||||
return .none
|
||||
case .run(let operation):
|
||||
return .run {
|
||||
let action = try await operation()
|
||||
return transform(action)
|
||||
}
|
||||
case .cancellable(let id, let operation):
|
||||
return .cancellable(id: id) {
|
||||
let action = try await operation()
|
||||
return transform(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Composition tradeoff: a single app-wide `AppIntent`/`AppAction` can become deeply nested as feature count grows. Prefer feature-scoped stores where practical, and compose only at flow boundaries (for example tab root, checkout flow, onboarding) instead of forcing one global mega-enum.
|
||||
|
||||
## View Guidance
|
||||
|
||||
- Render `store.state` only.
|
||||
- Send user events through `store.send(intent)`.
|
||||
- Never mutate domain state directly in views.
|
||||
|
||||
### SwiftUI Integration
|
||||
|
||||
```swift
|
||||
struct CounterView: View {
|
||||
@StateObject var store: Store<CounterState, CounterIntent, CounterAction>
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Count: \(store.state.count)")
|
||||
if case .loading = store.state.load { ProgressView() }
|
||||
Button("+") { store.send(.incrementTapped) }
|
||||
Button("-") { store.send(.decrementTapped) }
|
||||
Button("Reset") { store.send(.resetTapped) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you target iOS 17+ for SwiftUI-first features, you can replace `ObservableObject`/`@Published` stores with `@Observable` stores (as in the MVVM playbook) and use `@State` + `@Bindable` in views. Keep `ObservableObject` when the same store must expose Combine publishers to UIKit.
|
||||
|
||||
### UIKit Integration
|
||||
|
||||
In UIKit, subscribe once, render from state, and map control events to intents.
|
||||
|
||||
```swift
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
final class CounterViewController: UIViewController {
|
||||
private let store: Store<CounterState, CounterIntent, CounterAction>
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(store: Store<CounterState, CounterIntent, CounterAction>) {
|
||||
self.store = store
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { return nil }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
store.$state
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] in self?.render($0) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@objc private func incrementTapped() {
|
||||
store.send(.incrementTapped)
|
||||
}
|
||||
|
||||
private func render(_ state: CounterState) {
|
||||
title = "Count: \(state.count)"
|
||||
// Update labels/buttons/loading from state only.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
UIKit rules:
|
||||
- keep all UI writes in `render(_:)`
|
||||
- convert delegate/target-action callbacks into `Intent`
|
||||
|
||||
## Concurrency Rules
|
||||
|
||||
- Track active tasks by intent/effect key where duplicate requests are possible.
|
||||
- Cancel stale in-flight work before starting a newer request.
|
||||
- Use request IDs when responses can arrive out-of-order.
|
||||
- Keep shared mutable service state in actors.
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. Side effects inside reducer:
|
||||
- Smell: analytics/network calls directly in reducer branch.
|
||||
- Fix: emit `Effect` and handle through action loop.
|
||||
|
||||
2. Intent and action merged:
|
||||
- Smell: one enum for both user input and effect output.
|
||||
- Fix: separate `Intent` and `Action`.
|
||||
|
||||
3. Multiple sources of truth:
|
||||
- Smell: local `@State` mirrors store state.
|
||||
- Fix: keep canonical state in store only.
|
||||
|
||||
4. Derived fields stored redundantly:
|
||||
- Smell: persisted `isEven` with `count`.
|
||||
- Fix: compute derived properties.
|
||||
|
||||
5. Monolithic reducer:
|
||||
- Smell: very large switch spanning unrelated domains.
|
||||
- Fix: split reducers by feature and combine.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
- Unit test intent reducer transitions.
|
||||
- Unit test action reducer success/failure transitions.
|
||||
- Verify cancellation and stale-response handling.
|
||||
- Keep tests deterministic with controlled services, schedulers, or clocks.
|
||||
- Assert state-machine behavior, not view details.
|
||||
|
||||
Example test suite:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
struct StubCounterService: CounterServicing {
|
||||
func increment() async throws -> Int { 1 }
|
||||
func decrement() async throws -> Int { 0 }
|
||||
func reset() async throws -> Int { 0 }
|
||||
}
|
||||
|
||||
final class CounterReducerTests: XCTestCase {
|
||||
func test_intentIncrement_setsLoading_andReturnsEffect() {
|
||||
var state = CounterState()
|
||||
let service = StubCounterService()
|
||||
|
||||
let effect = reduce(
|
||||
state: &state,
|
||||
intent: .incrementTapped,
|
||||
service: service
|
||||
)
|
||||
|
||||
XCTAssertEqual(state.load, .loading)
|
||||
XCTAssertNotNil(effect)
|
||||
}
|
||||
|
||||
func test_actionFailure_setsError_andStopsLoading() {
|
||||
var state = CounterState(load: .loaded(3))
|
||||
|
||||
reduce(state: &state, action: .incrementResponse(.failure(TestError.offline)))
|
||||
|
||||
XCTAssertEqual(state.count, 0)
|
||||
if case .failed = state.load {
|
||||
// expected
|
||||
} else {
|
||||
XCTFail("Expected failed state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchState: Equatable {
|
||||
var latestRequestID: UUID?
|
||||
var results: [String] = []
|
||||
}
|
||||
|
||||
enum SearchAction {
|
||||
case response(requestID: UUID, Result<[String], Error>)
|
||||
}
|
||||
|
||||
func reduce(state: inout SearchState, action: SearchAction) {
|
||||
switch action {
|
||||
case .response(let requestID, .success(let results)):
|
||||
guard requestID == state.latestRequestID else { return }
|
||||
state.results = results
|
||||
case .response:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
final class SearchReducerTests: XCTestCase {
|
||||
func test_matchingLatestRequest_updatesResults() {
|
||||
let requestID = UUID()
|
||||
var state = SearchState(latestRequestID: requestID, results: [])
|
||||
|
||||
reduce(
|
||||
state: &state,
|
||||
action: .response(requestID: requestID, .success(["new"]))
|
||||
)
|
||||
|
||||
XCTAssertEqual(state.results, ["new"])
|
||||
}
|
||||
|
||||
func test_staleResponse_isIgnored() {
|
||||
let latestID = UUID()
|
||||
let staleID = UUID()
|
||||
var state = SearchState(latestRequestID: latestID, results: ["current"])
|
||||
|
||||
reduce(
|
||||
state: &state,
|
||||
action: .response(requestID: staleID, .success(["old"]))
|
||||
)
|
||||
|
||||
XCTAssertEqual(state.results, ["current"])
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case offline
|
||||
}
|
||||
```
|
||||
|
||||
## When to Prefer MVI
|
||||
|
||||
Prefer MVI for:
|
||||
- complex state machines
|
||||
- heavy concurrency/effect orchestration
|
||||
- high determinism and testability requirements
|
||||
|
||||
Prefer MVVM when:
|
||||
- screen complexity is moderate
|
||||
- lower boilerplate is more important than strict state-machine modeling
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- State is value-based and canonical.
|
||||
- Reducers are deterministic and side-effect free.
|
||||
- Effects are isolated and mapped back into actions.
|
||||
- Cancellation/versioning exists for concurrent requests.
|
||||
- View sends intents only; no direct business mutation.
|
||||
- Reducer tests cover success, failure, and cancellation.
|
||||
410
.agents/skills/swift-architecture-skill/references/mvp.md
Normal file
410
.agents/skills/swift-architecture-skill/references/mvp.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# MVP Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference when you need a passive View that delegates all logic to a Presenter, especially in UIKit codebases where direct testability of presentation logic is a priority.
|
||||
|
||||
## Core Boundaries
|
||||
|
||||
- Model: Domain entities and business rules. No UI dependencies.
|
||||
- View: Passive renderer driven entirely by Presenter commands. Owns no logic.
|
||||
- Presenter: Owns all presentation logic, maps Model data to display output, and drives View updates through a protocol.
|
||||
- Services/Repositories: Side-effect boundaries (network, persistence) injected into Presenter.
|
||||
|
||||
Dependency direction:
|
||||
|
||||
```text
|
||||
View -> Presenter (user actions)
|
||||
Presenter -> View (via ViewProtocol, one-way commands)
|
||||
Presenter -> Repository/Service (via protocols)
|
||||
```
|
||||
|
||||
The key difference from MVVM: the View holds no observable state — it passively executes commands dispatched by the Presenter.
|
||||
|
||||
## Feature Structure
|
||||
|
||||
```text
|
||||
App/
|
||||
Features/
|
||||
Profile/
|
||||
ProfileViewController.swift (View)
|
||||
ProfilePresenter.swift
|
||||
ProfileViewProtocol.swift
|
||||
ProfileViewData.swift
|
||||
ProfileAssembly.swift
|
||||
Navigation/
|
||||
AppCoordinator.swift
|
||||
Domain/
|
||||
Entities/
|
||||
Repositories/
|
||||
Data/
|
||||
Repositories/
|
||||
API/
|
||||
```
|
||||
|
||||
## View Protocol
|
||||
|
||||
Define the View as a weak protocol. The Presenter drives state through it.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol ProfileView: AnyObject {
|
||||
func showLoading(_ isLoading: Bool)
|
||||
func show(profile: ProfileViewData)
|
||||
func showError(message: String)
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- use `AnyObject` to allow weak references
|
||||
- methods represent view commands, not state flags
|
||||
- keep the protocol focused — one command per distinct UI concern
|
||||
|
||||
## View Data
|
||||
|
||||
Map domain entities to display-ready values in the Presenter, not the View.
|
||||
|
||||
```swift
|
||||
struct ProfileViewData: Equatable {
|
||||
let displayName: String
|
||||
let badgeText: String?
|
||||
let formattedJoinDate: String
|
||||
}
|
||||
```
|
||||
|
||||
## Presenter Pattern
|
||||
|
||||
Own task management, cancel stale work, and gate updates by request identity.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class ProfilePresenter {
|
||||
weak var view: ProfileView?
|
||||
private let repository: ProfileRepository
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var latestRequestID: UUID?
|
||||
|
||||
init(repository: ProfileRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func viewDidAppear() {
|
||||
load()
|
||||
}
|
||||
|
||||
func load() {
|
||||
let requestID = UUID()
|
||||
latestRequestID = requestID
|
||||
loadTask?.cancel()
|
||||
view?.showLoading(true)
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let user = try await repository.fetchCurrentUser()
|
||||
try Task.checkCancellation()
|
||||
guard latestRequestID == requestID else { return }
|
||||
let viewData = ProfileViewData(user: user)
|
||||
view?.show(profile: viewData)
|
||||
} catch is CancellationError {
|
||||
// Cancelled by a newer request — do not update view.
|
||||
} catch {
|
||||
guard latestRequestID == requestID else { return }
|
||||
view?.showError(message: "Failed to load profile. Please try again.")
|
||||
}
|
||||
guard latestRequestID == requestID else { return }
|
||||
view?.showLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewData {
|
||||
init(user: User) {
|
||||
self.displayName = user.name
|
||||
self.badgeText = user.isPremium ? "Premium" : nil
|
||||
self.formattedJoinDate = user.joinDate.formatted(.dateTime.year().month())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `view` is `weak` to avoid retain cycles
|
||||
- cancel in-flight task before starting a new one
|
||||
- gate state updates by `requestID` to prevent stale overwrites
|
||||
|
||||
## UIKit View Implementation
|
||||
|
||||
The UIKit view controller forwards actions to Presenter and executes view commands.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class ProfileViewController: UIViewController, ProfileView {
|
||||
private let presenter: ProfilePresenter
|
||||
private let nameLabel = UILabel()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let errorLabel = UILabel()
|
||||
|
||||
init(presenter: ProfilePresenter) {
|
||||
self.presenter = presenter
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
presenter.viewDidAppear()
|
||||
}
|
||||
|
||||
// MARK: - ProfileView
|
||||
|
||||
func showLoading(_ isLoading: Bool) {
|
||||
isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func show(profile: ProfileViewData) {
|
||||
nameLabel.text = profile.displayName
|
||||
errorLabel.isHidden = true
|
||||
}
|
||||
|
||||
func showError(message: String) {
|
||||
errorLabel.text = message
|
||||
errorLabel.isHidden = false
|
||||
}
|
||||
|
||||
private func setupLayout() {
|
||||
// Layout setup omitted for brevity.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SwiftUI Adapter
|
||||
|
||||
For SwiftUI, bridge via a thin observable adapter that conforms to `ProfileView`.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProfileViewAdapter: ProfileView {
|
||||
private(set) var viewData: ProfileViewData?
|
||||
private(set) var isLoading = false
|
||||
private(set) var errorMessage: String?
|
||||
private let presenter: ProfilePresenter
|
||||
|
||||
init(presenter: ProfilePresenter) {
|
||||
self.presenter = presenter
|
||||
presenter.view = self
|
||||
}
|
||||
|
||||
func showLoading(_ isLoading: Bool) {
|
||||
self.isLoading = isLoading
|
||||
}
|
||||
|
||||
func show(profile: ProfileViewData) {
|
||||
self.viewData = profile
|
||||
self.errorMessage = nil
|
||||
}
|
||||
|
||||
func showError(message: String) {
|
||||
self.errorMessage = message
|
||||
}
|
||||
|
||||
func viewDidAppear() { presenter.viewDidAppear() }
|
||||
}
|
||||
|
||||
struct ProfileScreen: View {
|
||||
@State private var adapter: ProfileViewAdapter
|
||||
|
||||
init(adapter: ProfileViewAdapter) {
|
||||
self._adapter = State(initialValue: adapter)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if adapter.isLoading {
|
||||
ProgressView()
|
||||
} else if let viewData = adapter.viewData {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewData.displayName).font(.title)
|
||||
if let badge = viewData.badgeText {
|
||||
Text(badge).font(.caption)
|
||||
}
|
||||
}
|
||||
} else if let error = adapter.errorMessage {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.onAppear { adapter.viewDidAppear() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Assembly
|
||||
|
||||
Wire dependencies in one place — the assembler or coordinator.
|
||||
|
||||
```swift
|
||||
enum ProfileAssembly {
|
||||
static func build(repository: ProfileRepository) -> UIViewController {
|
||||
let presenter = ProfilePresenter(repository: repository)
|
||||
let viewController = ProfileViewController(presenter: presenter)
|
||||
presenter.view = viewController
|
||||
return viewController
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func buildSwiftUI(repository: ProfileRepository) -> ProfileScreen {
|
||||
let presenter = ProfilePresenter(repository: repository)
|
||||
let adapter = ProfileViewAdapter(presenter: presenter)
|
||||
return ProfileScreen(adapter: adapter)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- set `presenter.view` after construction, not inside the Presenter initializer
|
||||
- inject concrete repositories from the composition root
|
||||
- keep the assembly function as the only place that creates the full module
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. View containing logic:
|
||||
- Smell: UIViewController computes display strings, formats dates, or makes service calls.
|
||||
- Fix: move all logic to Presenter; View receives ready-to-render view data.
|
||||
|
||||
2. Presenter observing state objects (ViewModel pattern leaking in):
|
||||
- Smell: Presenter publishes `@Published` properties that the View observes directly.
|
||||
- Fix: keep the Presenter command-driven; View state is driven by protocol method calls, not KVO or Combine pipelines.
|
||||
|
||||
3. Bidirectional strong references:
|
||||
- Smell: Presenter holds a strong reference to View.
|
||||
- Fix: declare `weak var view: ProfileView?` in Presenter.
|
||||
|
||||
4. No request-identity guard:
|
||||
- Smell: rapid re-loads overwrite each other because any in-flight completion can update the View.
|
||||
- Fix: assign a `UUID` per request and guard all view updates behind identity equality.
|
||||
|
||||
5. Fat Presenter:
|
||||
- Smell: Presenter contains network code, caching logic, or routing details.
|
||||
- Fix: delegate network and persistence to injected Repository protocols; delegate navigation to an injected Router or Coordinator.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Test the Presenter in isolation with a mock View and stub Repository.
|
||||
Verify the Presenter-to-View contract for success, failure, and cancellation paths.
|
||||
Keep tests deterministic by controlling async behaviour with stubs, not `sleep`.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class MockProfileView: ProfileView {
|
||||
var isLoading = false
|
||||
var shownViewData: ProfileViewData?
|
||||
var shownError: String?
|
||||
|
||||
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
|
||||
func show(profile: ProfileViewData) { shownViewData = profile }
|
||||
func showError(message: String) { shownError = message }
|
||||
}
|
||||
|
||||
struct StubProfileRepository: ProfileRepository {
|
||||
var result: Result<User, Error>
|
||||
func fetchCurrentUser() async throws -> User { try result.get() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProfilePresenterTests: XCTestCase {
|
||||
func test_load_success_showsUserName() async {
|
||||
let user = User(id: UUID(), name: "Alice", isPremium: false, joinDate: .now)
|
||||
let view = MockProfileView()
|
||||
let presenter = ProfilePresenter(
|
||||
repository: StubProfileRepository(result: .success(user))
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownViewData?.displayName, "Alice")
|
||||
XCTAssertNil(view.shownError)
|
||||
}
|
||||
|
||||
func test_load_failure_showsError() async {
|
||||
let view = MockProfileView()
|
||||
let presenter = ProfilePresenter(
|
||||
repository: StubProfileRepository(result: .failure(TestError.notFound))
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertNotNil(view.shownError)
|
||||
XCTAssertNil(view.shownViewData)
|
||||
}
|
||||
|
||||
func test_load_cancellation_doesNotOverwriteExistingViewData() async {
|
||||
let existing = User(id: UUID(), name: "Existing", isPremium: false, joinDate: .now)
|
||||
let view = MockProfileView()
|
||||
view.show(profile: ProfileViewData(user: existing))
|
||||
let presenter = ProfilePresenter(
|
||||
repository: StubProfileRepository(result: .failure(CancellationError()))
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownViewData?.displayName, "Existing")
|
||||
}
|
||||
|
||||
func test_rapidLoads_onlyLatestResultShown() async {
|
||||
let firstUser = User(id: UUID(), name: "First", isPremium: false, joinDate: .now)
|
||||
let view = MockProfileView()
|
||||
let presenter = ProfilePresenter(
|
||||
repository: StubProfileRepository(result: .success(firstUser))
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
// Simulate two rapid loads; second call cancels first.
|
||||
presenter.load() // request A — will be cancelled
|
||||
presenter.load() // request B — latest
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownViewData?.displayName, "First")
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error { case notFound }
|
||||
```
|
||||
|
||||
## When to Prefer MVP
|
||||
|
||||
Prefer MVP when:
|
||||
- UIKit is the primary stack and you want full Presenter testability without observable state objects
|
||||
- the View must be completely passive (no `if` logic, no `guard`, no formatting)
|
||||
- migrating from MVC and want a minimal step up without pulling in Combine or the `@Observable` macro
|
||||
- existing team is familiar with the Presenter + View protocol pattern
|
||||
|
||||
Prefer MVVM when:
|
||||
- SwiftUI is the primary stack and `@Observable` / `@Published` state binding reduces wiring overhead
|
||||
- you want reactive data flow with less hand-written command dispatch
|
||||
|
||||
Compared with VIPER, MVP omits the Interactor and Router as distinct components, making it lighter and simpler for single-screen features.
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- View contains no business logic, data formatting, or service calls.
|
||||
- `view` property in Presenter is `weak` and typed as `ProfileView`.
|
||||
- Presenter cancels the previous task before starting a new load.
|
||||
- All Presenter-to-View calls are guarded by request identity where async.
|
||||
- Repository and service dependencies are injected via protocols, not singletons.
|
||||
- Tests cover success, failure, and stale-cancellation paths.
|
||||
- Assembly function wires the module from the outside — Presenter does not create its own dependencies.
|
||||
769
.agents/skills/swift-architecture-skill/references/mvvm.md
Normal file
769
.agents/skills/swift-architecture-skill/references/mvvm.md
Normal file
@@ -0,0 +1,769 @@
|
||||
# MVVM Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference for MVVM requests or screen-level state with async effects.
|
||||
|
||||
## Core Boundaries
|
||||
|
||||
- Model: Domain entities and business rules. Keep UI-framework independent.
|
||||
- View: Render state and forward user intents. Do not call services directly.
|
||||
- ViewModel: Own presentation state, map domain to view data, coordinate effects.
|
||||
- Services/Repositories: Side-effect boundaries (network, persistence, analytics).
|
||||
|
||||
Dependency direction:
|
||||
- View -> ViewModel
|
||||
- ViewModel -> UseCases/Repositories/Services (via protocols)
|
||||
- Model -> no dependency on View/ViewModel
|
||||
|
||||
## Feature Structure
|
||||
|
||||
Prefer vertical feature slices with clear boundaries. Treat this layout as illustrative, not a required file checklist for every feature:
|
||||
|
||||
```text
|
||||
App/
|
||||
Features/
|
||||
Feed/
|
||||
FeedView.swift
|
||||
FeedViewModel.swift
|
||||
FeedState.swift
|
||||
FeedViewData.swift
|
||||
FeedDestination.swift
|
||||
FeedAssembly.swift
|
||||
Navigation/
|
||||
AppRouter.swift
|
||||
DeepLink.swift
|
||||
Domain/
|
||||
Entities/
|
||||
UseCases/
|
||||
Data/
|
||||
Repositories/
|
||||
API/
|
||||
Persistence/
|
||||
```
|
||||
|
||||
## State Modeling
|
||||
|
||||
Use explicit state types over boolean combinations.
|
||||
|
||||
```swift
|
||||
enum Loadable<Value: Equatable>: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
struct FeedItemViewData: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
}
|
||||
|
||||
struct ToastState: Equatable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct FeedState: Equatable {
|
||||
var load: Loadable<Void> = .idle
|
||||
var items: [FeedItemViewData] = []
|
||||
var isRefreshing = false
|
||||
var toast: ToastState?
|
||||
}
|
||||
```
|
||||
|
||||
## ViewModel Pattern
|
||||
|
||||
Keep mutation on main actor, own task handles, and cancel stale work.
|
||||
|
||||
### Modern Pattern (iOS 17+ / `@Observable`)
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedViewModel {
|
||||
private(set) var state = FeedState()
|
||||
|
||||
private let repository: FeedRepository
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
init(repository: FeedRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
guard case .idle = state.load else { return }
|
||||
load()
|
||||
}
|
||||
|
||||
func load() {
|
||||
loadTask?.cancel()
|
||||
state.load = .loading
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let page = try await repository.fetchPage(cursor: nil)
|
||||
try Task.checkCancellation()
|
||||
state.items = page.items.map(FeedItemViewData.init)
|
||||
state.load = .loaded(())
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation.
|
||||
} catch {
|
||||
state.load = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Legacy Pattern (iOS 16 and earlier / `ObservableObject`)
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class FeedViewModel: ObservableObject {
|
||||
@Published private(set) var state = FeedState()
|
||||
|
||||
private let repository: FeedRepository
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
init(repository: FeedRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
guard case .idle = state.load else { return }
|
||||
load()
|
||||
}
|
||||
|
||||
func load() {
|
||||
loadTask?.cancel()
|
||||
state.load = .loading
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let page = try await repository.fetchPage(cursor: nil)
|
||||
try Task.checkCancellation()
|
||||
state.items = page.items.map(FeedItemViewData.init)
|
||||
state.load = .loaded(())
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation.
|
||||
} catch {
|
||||
state.load = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Inject abstractions into ViewModel constructors. Build live dependencies in feature assembly.
|
||||
|
||||
```swift
|
||||
protocol FeedRepository {
|
||||
func fetchPage(cursor: String?) async throws -> FeedPage
|
||||
}
|
||||
|
||||
enum FeedAssembly {
|
||||
static func makeViewModel() -> FeedViewModel {
|
||||
FeedViewModel(repository: LiveFeedRepository(api: .live))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`FeedAssembly.makeViewModel()` keeps feature wiring obvious, but can become limiting as apps grow. A common evolution path is an app-level dependency container (composition root) that owns shared dependency graphs.
|
||||
|
||||
```swift
|
||||
protocol AppDependencies {
|
||||
var feedRepository: FeedRepository { get }
|
||||
}
|
||||
|
||||
struct LiveDependencies: AppDependencies {
|
||||
private let api: APIClient
|
||||
|
||||
init(api: APIClient) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
var feedRepository: FeedRepository {
|
||||
LiveFeedRepository(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppContainer {
|
||||
private let dependencies: AppDependencies
|
||||
|
||||
init(dependencies: AppDependencies) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
func makeFeedViewModel() -> FeedViewModel {
|
||||
FeedViewModel(repository: dependencies.feedRepository)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View Guidance
|
||||
|
||||
- Bind to ViewModel state only.
|
||||
- Keep business transforms out of `body`/`cellForRowAt`.
|
||||
- Expose dedicated `ViewData` structs for formatting and display concerns.
|
||||
- Keep View-local state only for transient UI details (focus, scroll position).
|
||||
|
||||
SwiftUI view with `@Observable` ViewModel (iOS 17+):
|
||||
|
||||
```swift
|
||||
struct FeedView: View {
|
||||
@State private var viewModel: FeedViewModel
|
||||
|
||||
init(viewModel: FeedViewModel) {
|
||||
_viewModel = State(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.state.items, id: \.id) { item in
|
||||
Text(item.title)
|
||||
}
|
||||
.task { viewModel.onAppear() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SwiftUI view with `ObservableObject` ViewModel (iOS 16 and earlier):
|
||||
|
||||
```swift
|
||||
struct FeedView: View {
|
||||
@StateObject private var viewModel: FeedViewModel
|
||||
|
||||
init(viewModel: FeedViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.state.items, id: \.id) { item in
|
||||
Text(item.title)
|
||||
}
|
||||
.task { viewModel.onAppear() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Patterns
|
||||
|
||||
Keep routing decisions testable and decoupled from presentation APIs: ViewModel decides *where*, routing layer decides *how*.
|
||||
|
||||
### SwiftUI Navigation (iOS 16+ / `NavigationStack`)
|
||||
|
||||
Model destinations as an enum. Prefer stable IDs over list-specific `ViewData`.
|
||||
|
||||
Path ownership is a real tradeoff:
|
||||
- ViewModel-owned path: simplest end-to-end SwiftUI wiring, but mixes data/loading state with navigation state.
|
||||
- View-owned path: keeps ViewModel state focused on data/loading, but requires an intent API so route decisions stay testable.
|
||||
- Router-owned path: best for multi-screen flows and deep links, with extra types/wiring cost.
|
||||
|
||||
The examples below show ViewModel-owned and router-owned patterns.
|
||||
|
||||
```swift
|
||||
enum FeedDestination: Hashable {
|
||||
case detail(id: UUID)
|
||||
case profile(userId: UUID)
|
||||
case settings
|
||||
}
|
||||
```
|
||||
|
||||
Option A: ViewModel-owned path.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedViewModel {
|
||||
private(set) var state = FeedState()
|
||||
var navigationPath: [FeedDestination] = []
|
||||
|
||||
// ...existing properties...
|
||||
|
||||
func didTapItem(_ item: FeedItemViewData) {
|
||||
navigationPath.append(.detail(id: item.id))
|
||||
}
|
||||
|
||||
func didTapProfile(userId: UUID) {
|
||||
navigationPath.append(.profile(userId: userId))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
View binds the path to `NavigationStack`:
|
||||
|
||||
```swift
|
||||
struct FeedView: View {
|
||||
@State private var viewModel: FeedViewModel
|
||||
|
||||
var body: some View {
|
||||
@Bindable var viewModel = viewModel
|
||||
|
||||
NavigationStack(path: $viewModel.navigationPath) {
|
||||
List(viewModel.state.items) { item in
|
||||
Button(item.title) {
|
||||
viewModel.didTapItem(item)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: FeedDestination.self) { destination in
|
||||
switch destination {
|
||||
case .detail(let itemID):
|
||||
FeedDetailView(viewModel: FeedDetailViewModel(itemID: itemID))
|
||||
case .profile(let userId):
|
||||
ProfileView(viewModel: ProfileViewModel(userId: userId))
|
||||
case .settings:
|
||||
SettingsView(viewModel: SettingsViewModel())
|
||||
}
|
||||
}
|
||||
.task { viewModel.onAppear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Option B: dedicated router keeps `FeedState` focused on presentation data/loading.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedRouter {
|
||||
var path: [FeedDestination] = []
|
||||
|
||||
func push(_ destination: FeedDestination) {
|
||||
path.append(destination)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedViewModel {
|
||||
private(set) var state = FeedState()
|
||||
|
||||
func destinationForItem(_ item: FeedItemViewData) -> FeedDestination {
|
||||
.detail(id: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedView: View {
|
||||
@State private var viewModel: FeedViewModel
|
||||
@State private var router = FeedRouter()
|
||||
|
||||
var body: some View {
|
||||
@Bindable var router = router
|
||||
|
||||
NavigationStack(path: $router.path) {
|
||||
List(viewModel.state.items) { item in
|
||||
Button(item.title) {
|
||||
router.push(viewModel.destinationForItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Modal / Sheet Presentation
|
||||
|
||||
Model sheet presentation as optional state on the ViewModel.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedViewModel {
|
||||
private(set) var state = FeedState()
|
||||
var activeSheet: FeedSheet?
|
||||
|
||||
struct FeedFilter: Equatable {
|
||||
var showUnreadOnly = false
|
||||
}
|
||||
|
||||
enum FeedSheet: Identifiable {
|
||||
case compose
|
||||
case filter(current: FeedFilter)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .compose: "compose"
|
||||
case .filter: "filter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didTapCompose() {
|
||||
activeSheet = .compose
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
struct FeedView: View {
|
||||
@State private var viewModel: FeedViewModel
|
||||
|
||||
var body: some View {
|
||||
@Bindable var viewModel = viewModel
|
||||
|
||||
List(viewModel.state.items) { item in
|
||||
Text(item.title)
|
||||
}
|
||||
.sheet(item: $viewModel.activeSheet) { sheet in
|
||||
switch sheet {
|
||||
case .compose:
|
||||
ComposeView(viewModel: ComposeViewModel())
|
||||
case .filter(let current):
|
||||
FilterView(viewModel: FilterViewModel(filter: current))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinator Pattern (UIKit or Mixed Codebases)
|
||||
|
||||
When UIKit is involved or complex multi-step flows require centralized control, use a Coordinator protocol.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol FeedCoordinator: AnyObject {
|
||||
func showDetail(itemID: UUID)
|
||||
func showProfile(userId: UUID)
|
||||
func presentCompose(onComplete: @MainActor @escaping () -> Void)
|
||||
}
|
||||
```
|
||||
|
||||
Inject the Coordinator into the ViewModel:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class FeedViewModel {
|
||||
private(set) var state = FeedState()
|
||||
|
||||
private let repository: FeedRepository
|
||||
private weak var coordinator: FeedCoordinator?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
|
||||
init(repository: FeedRepository, coordinator: FeedCoordinator) {
|
||||
self.repository = repository
|
||||
self.coordinator = coordinator
|
||||
}
|
||||
|
||||
func didTapItem(_ item: FeedItemViewData) {
|
||||
coordinator?.showDetail(itemID: item.id)
|
||||
}
|
||||
|
||||
func didTapCompose() {
|
||||
coordinator?.presentCompose { [weak self] in
|
||||
self?.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Concrete implementation lives in the navigation layer:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class FeedFlowCoordinator: FeedCoordinator {
|
||||
private let navigationController: UINavigationController
|
||||
|
||||
init(navigationController: UINavigationController) {
|
||||
self.navigationController = navigationController
|
||||
}
|
||||
|
||||
func showDetail(itemID: UUID) {
|
||||
let viewModel = FeedDetailAssembly.makeViewModel(itemID: itemID)
|
||||
let vc = UIHostingController(rootView: FeedDetailView(viewModel: viewModel))
|
||||
navigationController.pushViewController(vc, animated: true)
|
||||
}
|
||||
|
||||
func showProfile(userId: UUID) {
|
||||
let viewModel = ProfileAssembly.makeViewModel(userId: userId)
|
||||
let vc = UIHostingController(rootView: ProfileView(viewModel: viewModel))
|
||||
navigationController.pushViewController(vc, animated: true)
|
||||
}
|
||||
|
||||
func presentCompose(onComplete: @MainActor @escaping () -> Void) {
|
||||
let composeVM = ComposeAssembly.makeViewModel(onComplete: onComplete)
|
||||
let vc = UIHostingController(rootView: ComposeView(viewModel: composeVM))
|
||||
navigationController.present(vc, animated: true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deep Linking
|
||||
|
||||
Centralize deep link resolution in a router that maps URLs to navigation destinations.
|
||||
|
||||
```swift
|
||||
enum DeepLink {
|
||||
case feedItem(id: UUID)
|
||||
case profile(userId: UUID)
|
||||
case settings
|
||||
|
||||
init?(url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let host = components.host else { return nil }
|
||||
switch host {
|
||||
case "feed":
|
||||
guard let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
|
||||
let id = UUID(uuidString: idString) else { return nil }
|
||||
self = .feedItem(id: id)
|
||||
case "profile":
|
||||
guard let idString = components.queryItems?.first(where: { $0.name == "userId" })?.value,
|
||||
let id = UUID(uuidString: idString) else { return nil }
|
||||
self = .profile(userId: id)
|
||||
case "settings":
|
||||
self = .settings
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply deep links to existing navigation state:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppRouter {
|
||||
var feedViewModel: FeedViewModel
|
||||
|
||||
func handle(_ deepLink: DeepLink) {
|
||||
switch deepLink {
|
||||
case .feedItem(let id):
|
||||
feedViewModel.navigationPath = [.detail(id: id)]
|
||||
case .profile(let userId):
|
||||
feedViewModel.navigationPath = [.profile(userId: userId)]
|
||||
case .settings:
|
||||
feedViewModel.navigationPath = [.settings]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Which Pattern to Choose
|
||||
|
||||
| Scenario | Recommended Pattern |
|
||||
|---|---|
|
||||
| Pure SwiftUI, linear flows | `NavigationStack` path on ViewModel |
|
||||
| Sheets, alerts, confirmations | Optional state-driven presentation |
|
||||
| UIKit host or mixed SwiftUI/UIKit | Coordinator protocol |
|
||||
| Multi-step flows (onboarding, checkout) | Coordinator with child coordinators |
|
||||
| Universal Links / push notifications | Deep link router + state-driven nav |
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. God ViewModel:
|
||||
- Smell: networking, parsing, persistence, and state orchestration all in one class.
|
||||
- Fix: extract UseCases/Repositories; keep ViewModel focused on state and intent handling.
|
||||
|
||||
2. Duplicate state in View and ViewModel:
|
||||
- Smell: `@State var items` and `viewModel.state.items` coexist.
|
||||
- Fix: one source of truth in ViewModel.
|
||||
|
||||
3. Stale async overwrite:
|
||||
- Smell: older response replaces newer state.
|
||||
- Fix: cancel in-flight task before new request and check cancellation.
|
||||
|
||||
4. Navigation logic inside ViewModel with UIKit types:
|
||||
- Smell: direct `UINavigationController` usage in ViewModel.
|
||||
- Fix: inject Router/Coordinator protocol.
|
||||
|
||||
5. Heavy work on main actor:
|
||||
- Smell: decoding or expensive mapping in main-actor methods.
|
||||
- Fix: move heavy CPU work off-main; assign final state on main actor.
|
||||
|
||||
```swift
|
||||
// Anti-pattern: expensive mapping runs on @MainActor.
|
||||
@MainActor
|
||||
func load() {
|
||||
loadTask?.cancel()
|
||||
state.load = .loading
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let page = try await repository.fetchPage(cursor: nil)
|
||||
state.items = page.items.map(FeedItemViewData.init) // can hitch UI for large pages
|
||||
state.load = .loaded(())
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation.
|
||||
} catch {
|
||||
state.load = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Better: do CPU-heavy mapping off actor, then commit state on @MainActor.
|
||||
@MainActor
|
||||
func load() {
|
||||
loadTask?.cancel()
|
||||
state.load = .loading
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let page = try await repository.fetchPage(cursor: nil)
|
||||
let mappedItems = try await Task.detached(priority: .userInitiated) {
|
||||
page.items.map(FeedItemViewData.init)
|
||||
}.value
|
||||
try Task.checkCancellation()
|
||||
state.items = mappedItems
|
||||
state.load = .loaded(())
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation.
|
||||
} catch {
|
||||
state.load = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If mapping is small but reused, extract it into a pure helper (`static`/`nonisolated`) for testability; if it is expensive, run it off actor (`Task.detached` or a background service). Under strict concurrency (Swift 6), ensure detached-task captures/results are `Sendable`, or move the work behind a background actor/service boundary.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
Focus on deterministic state transitions:
|
||||
- success path (`loading -> loaded`)
|
||||
- failure path (`loading -> failed`)
|
||||
- cancellation path (no stale overwrite)
|
||||
- mapping correctness (domain -> view data)
|
||||
|
||||
Test strategy:
|
||||
- Use protocol stubs/fakes for repositories.
|
||||
- Avoid sleep-based tests; use controllable stub responses.
|
||||
- If ViewModel is `@MainActor`, run assertions through `await MainActor.run`.
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
struct FeedItem: Equatable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
}
|
||||
|
||||
struct FeedPage: Equatable {
|
||||
let items: [FeedItem]
|
||||
}
|
||||
|
||||
extension FeedItemViewData {
|
||||
init(_ item: FeedItem) {
|
||||
self.id = item.id
|
||||
self.title = item.title
|
||||
}
|
||||
}
|
||||
|
||||
actor ControlledFeedRepository: FeedRepository {
|
||||
private var continuations: [CheckedContinuation<FeedPage, Error>] = []
|
||||
|
||||
func fetchPage(cursor: String?) async throws -> FeedPage {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
continuations.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveNext(with result: Result<FeedPage, Error>) {
|
||||
guard !continuations.isEmpty else { return }
|
||||
let continuation = continuations.removeFirst()
|
||||
switch result {
|
||||
case .success(let page):
|
||||
continuation.resume(returning: page)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FeedViewModelTests: XCTestCase {
|
||||
func test_load_success_setsLoadedAndMapsItems() async {
|
||||
let repository = ControlledFeedRepository()
|
||||
let sut = FeedViewModel(repository: repository)
|
||||
let expected = FeedPage(items: [FeedItem(id: UUID(), title: "A")])
|
||||
|
||||
sut.load()
|
||||
await repository.resolveNext(with: .success(expected))
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(sut.state.items.map(\.title), ["A"])
|
||||
if case .loaded = sut.state.load {
|
||||
// expected
|
||||
} else {
|
||||
XCTFail("Expected loaded state")
|
||||
}
|
||||
}
|
||||
|
||||
func test_load_failure_setsFailed() async {
|
||||
let repository = ControlledFeedRepository()
|
||||
let sut = FeedViewModel(repository: repository)
|
||||
|
||||
sut.load()
|
||||
await repository.resolveNext(with: .failure(TestError.offline))
|
||||
await Task.yield()
|
||||
|
||||
if case .failed = sut.state.load {
|
||||
// expected
|
||||
} else {
|
||||
XCTFail("Expected failed state")
|
||||
}
|
||||
}
|
||||
|
||||
func test_load_cancellation_ignoresStaleResult() async {
|
||||
let repository = ControlledFeedRepository()
|
||||
let sut = FeedViewModel(repository: repository)
|
||||
|
||||
let stale = FeedPage(items: [FeedItem(id: UUID(), title: "stale")])
|
||||
let latest = FeedPage(items: [FeedItem(id: UUID(), title: "latest")])
|
||||
|
||||
sut.load() // request A
|
||||
sut.load() // request B cancels A
|
||||
|
||||
await repository.resolveNext(with: .success(stale))
|
||||
await repository.resolveNext(with: .success(latest))
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(sut.state.items.map(\.title), ["latest"])
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case offline
|
||||
}
|
||||
```
|
||||
|
||||
## When to Prefer MVVM
|
||||
|
||||
Prefer MVVM when:
|
||||
- screen-level state management is the primary concern
|
||||
- team wants explicit View/ViewModel boundaries without introducing a full reducer/store framework
|
||||
- feature complexity is moderate and does not require strict unidirectional flow
|
||||
- the team accepts moderate structure (for example, `State`, `ViewData`, assembly/router types) in exchange for clarity and testability
|
||||
|
||||
MVVM is often lower ceremony than TCA/VIPER, but not "no ceremony." A strict MVVM style can introduce several files per feature; scale file splitting to actual complexity instead of applying every type up front.
|
||||
|
||||
Prefer MVI/TCA when:
|
||||
- deterministic state-machine modeling is required
|
||||
- complex effect orchestration and cancellation correctness are critical
|
||||
|
||||
Prefer Clean Architecture/VIPER when:
|
||||
- strict layer boundaries and use-case isolation matter more than presentation-layer simplicity
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- View does not call services directly.
|
||||
- ViewModel exposes explicit state model.
|
||||
- Dependencies are injected (no app-wide singleton dependency in ViewModel).
|
||||
- Async tasks have cancellation strategy.
|
||||
- Domain models are not directly coupled to View rendering.
|
||||
- Navigation destinations are modeled as value types (enum/struct), not imperative calls.
|
||||
- ViewModel does not import UIKit or reference presentation APIs directly.
|
||||
- Deep link handling routes through a centralized router, not ad-hoc view logic.
|
||||
- Unit tests cover success, failure, and cancellation.
|
||||
327
.agents/skills/swift-architecture-skill/references/reactive.md
Normal file
327
.agents/skills/swift-architecture-skill/references/reactive.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Reactive Architecture Playbook (Swift + Combine/RxSwift)
|
||||
|
||||
Use this reference for stream-driven features (search, live updates, real-time feeds).
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Model inputs, transforms, and outputs as streams.
|
||||
|
||||
```text
|
||||
Input -> Publisher/Observable chain -> State -> UI
|
||||
```
|
||||
|
||||
Keep stream composition in presentation or a dedicated reactive layer, not in views.
|
||||
|
||||
## Canonical Combine Pattern
|
||||
|
||||
```swift
|
||||
final class SearchViewModel<S: Scheduler>: ObservableObject
|
||||
where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
|
||||
@Published var query = ""
|
||||
@Published private(set) var results: [String] = []
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(service: SearchService, scheduler: S) {
|
||||
$query
|
||||
.debounce(for: .milliseconds(300), scheduler: scheduler)
|
||||
.removeDuplicates()
|
||||
.map { query in
|
||||
service.search(query)
|
||||
.replaceError(with: [])
|
||||
}
|
||||
.switchToLatest()
|
||||
.receive(on: scheduler)
|
||||
.sink { [weak self] values in
|
||||
self?.results = values
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In production, pass `DispatchQueue.main` as the scheduler.
|
||||
|
||||
Rules:
|
||||
- debounce user text input
|
||||
- remove duplicates where meaningful
|
||||
- hop to main thread before UI-bound state writes
|
||||
- keep cancellables tied to lifecycle
|
||||
|
||||
## UI Integration by Stack
|
||||
|
||||
### SwiftUI Pattern
|
||||
|
||||
- Keep operator chains in `ObservableObject`/`@Observable` types, not in `View`.
|
||||
- Bind UI input (`TextField`, toggle, selection) to published inputs on the model.
|
||||
|
||||
### UIKit Pattern (Combine)
|
||||
|
||||
- Keep pipelines in Presenter/ViewModel.
|
||||
- Map delegate/target-action callbacks into input subjects.
|
||||
- Render from a single state subscription.
|
||||
|
||||
```swift
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class SearchPresenter<S: Scheduler> where S.SchedulerTimeType == DispatchQueue.SchedulerTimeType {
|
||||
let state = CurrentValueSubject<SearchResultState, Never>(.loaded([]))
|
||||
private let query = PassthroughSubject<String, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(service: SearchService, scheduler: S) {
|
||||
query
|
||||
.debounce(for: .milliseconds(300), scheduler: scheduler)
|
||||
.removeDuplicates()
|
||||
.map { value in
|
||||
service.search(value)
|
||||
.map(SearchResultState.loaded)
|
||||
.catch { Just(.failed($0.localizedDescription)) }
|
||||
}
|
||||
.switchToLatest()
|
||||
.sink { [weak self] in self?.state.send($0) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func queryChanged(_ text: String) { query.send(text) }
|
||||
}
|
||||
|
||||
// In production, pass DispatchQueue.main as the scheduler.
|
||||
|
||||
final class SearchViewController: UIViewController, UISearchBarDelegate {
|
||||
private let presenter: SearchPresenter<DispatchQueue>
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(presenter: SearchPresenter<DispatchQueue>) {
|
||||
self.presenter = presenter
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { return nil }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
presenter.state
|
||||
.sink { [weak self] in self?.render($0) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
presenter.queryChanged(searchText)
|
||||
}
|
||||
|
||||
private func render(_ state: SearchResultState) {
|
||||
// Render labels/list/error from stream state.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Operator Guidance
|
||||
|
||||
- `debounce`: stabilize noisy user input (search fields)
|
||||
- `throttle`: limit high-frequency events (scroll, sensor)
|
||||
- `flatMap`: merge concurrent async work when all responses matter
|
||||
- `switchToLatest`: keep only newest request (typeahead/search)
|
||||
- `share`: avoid duplicate side effects for multiple subscribers
|
||||
- `catch`: recover from recoverable errors with fallback streams
|
||||
|
||||
Prefer `switchToLatest` over nested subscriptions for request replacement flows.
|
||||
|
||||
## RxSwift Mapping Notes
|
||||
|
||||
Combine and RxSwift mapping:
|
||||
- `AnyPublisher` <-> `Observable`
|
||||
- `AnyCancellable` <-> `DisposeBag`
|
||||
- `receive(on:)` <-> `observe(on:)`
|
||||
- `subscribe(on:)` semantics should be applied intentionally to offload heavy work
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
Recover in stream boundaries and expose user-safe state:
|
||||
|
||||
```swift
|
||||
protocol SearchService {
|
||||
func search(_ query: String) -> AnyPublisher<[String], Error>
|
||||
}
|
||||
|
||||
enum SearchResultState: Equatable {
|
||||
case loaded([String])
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
func searchState(
|
||||
query: String,
|
||||
service: SearchService
|
||||
) -> AnyPublisher<SearchResultState, Never> {
|
||||
service.search(query)
|
||||
.map(SearchResultState.loaded)
|
||||
.catch { Just(.failed($0.localizedDescription)) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
```
|
||||
|
||||
For transient failures, prefer fallback state over terminating the stream.
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. Nested subscriptions:
|
||||
- Smell: subscribe inside subscribe, difficult cancellation and reasoning.
|
||||
- Fix: compose with `flatMap`/`switchToLatest`.
|
||||
|
||||
2. Missing cancellation/disposal:
|
||||
- Smell: stream continues after screen deallocation or rebind.
|
||||
- Fix: store `AnyCancellable` or use `DisposeBag` lifecycle correctly.
|
||||
|
||||
3. Business logic in view:
|
||||
- Smell: view constructs pipelines and calls services directly.
|
||||
- Fix: move stream orchestration to Presenter/ViewModel layer.
|
||||
|
||||
4. UI thread violations:
|
||||
- Smell: publishing UI-bound state off main thread.
|
||||
- Fix: apply `receive(on:)` / `observe(on:)` before UI mutations.
|
||||
|
||||
5. Unbounded fan-out:
|
||||
- Smell: many subscribers trigger duplicate network calls.
|
||||
- Fix: use `share`/multicasting where side effects should be single-execution.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Test stream behavior deterministically:
|
||||
- input -> expected output transitions
|
||||
- success path emits the expected state sequence
|
||||
- debounce/throttle behavior with controlled schedulers
|
||||
- cancellation behavior for replaced requests
|
||||
- error fallback behavior
|
||||
|
||||
Rules:
|
||||
- inject schedulers/time providers for tests
|
||||
- avoid real-time sleeps when possible
|
||||
- assert emitted state sequence, not internal operator details
|
||||
|
||||
```swift
|
||||
import Combine
|
||||
import CombineSchedulers
|
||||
import XCTest
|
||||
|
||||
final class SearchViewModelTests: XCTestCase {
|
||||
func test_queryEmitsResults() {
|
||||
let subject = PassthroughSubject<[String], Error>()
|
||||
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
|
||||
// Requires Point-Free's CombineSchedulers package.
|
||||
let scheduler = DispatchQueue.test
|
||||
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
|
||||
|
||||
var collected: [[String]] = []
|
||||
let cancellable = vm.$results
|
||||
.dropFirst()
|
||||
.sink { collected.append($0) }
|
||||
|
||||
vm.query = "swift"
|
||||
|
||||
// Advance past debounce interval.
|
||||
scheduler.advance(by: .milliseconds(300))
|
||||
|
||||
// Simulate service response.
|
||||
subject.send(["SwiftUI", "Swift"])
|
||||
subject.send(completion: .finished)
|
||||
|
||||
// Advance to process receive(on:).
|
||||
scheduler.advance()
|
||||
|
||||
XCTAssertEqual(collected, [["SwiftUI", "Swift"]])
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
func test_errorFallsBackToEmptyResults() {
|
||||
let subject = PassthroughSubject<[String], Error>()
|
||||
let stubService = StubSearchService { _ in subject.eraseToAnyPublisher() }
|
||||
let scheduler = DispatchQueue.test
|
||||
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
|
||||
|
||||
var collected: [[String]] = []
|
||||
let cancellable = vm.$results
|
||||
.dropFirst()
|
||||
.sink { collected.append($0) }
|
||||
|
||||
vm.query = "swift"
|
||||
scheduler.advance(by: .milliseconds(300))
|
||||
|
||||
subject.send(completion: .failure(TestError.offline))
|
||||
scheduler.advance()
|
||||
|
||||
XCTAssertEqual(collected, [[]])
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
func test_switchToLatest_ignoresStaleInFlightResponse() {
|
||||
let first = PassthroughSubject<[String], Error>()
|
||||
let second = PassthroughSubject<[String], Error>()
|
||||
let stubService = StubSearchService { query in
|
||||
switch query {
|
||||
case "sw":
|
||||
return first.eraseToAnyPublisher()
|
||||
case "swift":
|
||||
return second.eraseToAnyPublisher()
|
||||
default:
|
||||
return Empty<[String], Error>().eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
let scheduler = DispatchQueue.test
|
||||
let vm = SearchViewModel(service: stubService, scheduler: scheduler.eraseToAnyScheduler())
|
||||
|
||||
var collected: [[String]] = []
|
||||
let cancellable = vm.$results
|
||||
.dropFirst()
|
||||
.sink { collected.append($0) }
|
||||
|
||||
vm.query = "sw"
|
||||
scheduler.advance(by: .milliseconds(300))
|
||||
|
||||
vm.query = "swift"
|
||||
scheduler.advance(by: .milliseconds(300))
|
||||
|
||||
// This should be ignored because a newer query replaced the subscription.
|
||||
first.send(["stale"])
|
||||
second.send(["fresh"])
|
||||
scheduler.advance()
|
||||
|
||||
XCTAssertEqual(collected, [["fresh"]])
|
||||
cancellable.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct StubSearchService: SearchService {
|
||||
let searchHandler: (String) -> AnyPublisher<[String], Error>
|
||||
func search(_ query: String) -> AnyPublisher<[String], Error> {
|
||||
searchHandler(query)
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error {
|
||||
case offline
|
||||
}
|
||||
```
|
||||
|
||||
The canonical `SearchViewModel` already supports scheduler injection for tests.
|
||||
|
||||
## When to Prefer Reactive Architecture
|
||||
|
||||
Prefer when:
|
||||
- feature is event-heavy and stream-oriented
|
||||
- real-time updates and transformations are core behavior
|
||||
- composable async pipelines provide clarity over imperative callbacks
|
||||
|
||||
Prefer MVI/TCA when:
|
||||
- explicit state-machine and strict reducer flow are primary requirements
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- Streams are composed without nested subscriptions.
|
||||
- Cancellation/disposal is lifecycle-safe.
|
||||
- UI-bound updates are marshaled to main thread.
|
||||
- Operators match intent (`debounce`, `throttle`, `switchToLatest`, `share`).
|
||||
- Views/controllers do not hold business pipeline logic.
|
||||
- Error handling keeps UX resilient for transient failures.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Architecture Selection Guide
|
||||
|
||||
Use this reference when the user asks for an architecture recommendation.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Factor | MVVM | MVI | TCA | Clean | VIPER | Reactive | MVP | Coordinator |
|
||||
|--------|------|-----|-----|-------|-------|----------|-----|-------------|
|
||||
| State complexity | 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 `StoreOf` in views; UIKit uses a controller render loop from `ViewStore`.
|
||||
- **Clean Architecture**: Domain/data stay the same; only presentation adapters differ.
|
||||
- **VIPER**: UIKit-native fit; SwiftUI usually uses an adapter plus `UIHostingController`.
|
||||
- **Reactive**: SwiftUI keeps pipelines in observable models; UIKit keeps them in Presenter/ViewModel.
|
||||
- **MVP**: UIKit-native fit; Presenter drives passive View via protocol commands; SwiftUI uses an observable adapter.
|
||||
- **Coordinator**: Works with both stacks; UIKit uses `UINavigationController` wrapper; SwiftUI models navigation as value-type state bound to `NavigationStack`.
|
||||
|
||||
## Quick Decision Flow
|
||||
|
||||
```text
|
||||
1. Is the feature stream-heavy (search, live feeds, real-time updates)?
|
||||
YES -> Consider Reactive (references/reactive.md). If strict reducer/state-machine flow is also required, continue to step 2 and likely combine patterns.
|
||||
NO -> Continue
|
||||
|
||||
2. Is strict unidirectional data flow and state-machine modeling required?
|
||||
YES -> Is the app already TCA-based, or is adding TCA dependency acceptable?
|
||||
YES -> TCA (references/tca.md)
|
||||
NO -> MVI (references/mvi.md)
|
||||
NO -> Continue
|
||||
|
||||
3. Does the codebase need strict layer isolation with replaceable infrastructure?
|
||||
YES -> Clean Architecture (references/clean-architecture.md)
|
||||
NO -> Continue
|
||||
|
||||
4. Is this a large UIKit codebase needing strict per-feature separation?
|
||||
YES -> VIPER (references/viper.md)
|
||||
NO -> Continue
|
||||
|
||||
5. Is the primary goal decoupling navigation from screens (deep linking, reusable flows)?
|
||||
YES -> Coordinator (references/coordinator.md) — pair with a presentation pattern below
|
||||
NO -> Continue
|
||||
|
||||
6. Is UIKit the primary stack and a fully passive View with zero logic desired?
|
||||
YES -> MVP (references/mvp.md)
|
||||
NO -> Continue
|
||||
|
||||
7. Default recommendation:
|
||||
-> MVVM (references/mvvm.md)
|
||||
```
|
||||
|
||||
## Inference from User Constraints
|
||||
|
||||
Use these request signals:
|
||||
|
||||
### Signals pointing to MVVM
|
||||
- "simple feature", "screen-level state", "standard iOS pattern"
|
||||
- small/medium feature without strict state-machine needs
|
||||
|
||||
### Signals pointing to MVI
|
||||
- "state machine", "deterministic transitions", "unidirectional"
|
||||
- need to replay/serialize state transitions
|
||||
|
||||
### Signals pointing to TCA
|
||||
- "composable", "TestStore", "pointfree", mentions of TCA
|
||||
- existing TCA codebase or strong child-feature composition needs
|
||||
|
||||
### Signals pointing to Clean Architecture
|
||||
- "layers", "use cases", "dependency rule", "hexagonal"
|
||||
- stable module boundaries and replaceable infrastructure are priorities
|
||||
|
||||
### Signals pointing to VIPER
|
||||
- "module", "router", "presenter", legacy UIKit codebase
|
||||
- strict role separation in large UIKit modules
|
||||
|
||||
### Signals pointing to Reactive
|
||||
- "streams", "Combine", "RxSwift", "real-time", "search"
|
||||
- feature behavior is event-pipeline driven (typeahead, WebSocket, live feeds)
|
||||
|
||||
### Signals pointing to MVP
|
||||
- "passive view", "presenter drives view", "UIKit without observable state"
|
||||
- migrating from MVC with minimal framework changes
|
||||
- team prefers explicit command-dispatch over state binding
|
||||
|
||||
### Signals pointing to Coordinator
|
||||
- "navigation", "deep linking", "flow", "routing", "decouple navigation"
|
||||
- multiple screens need to be reused across different flows
|
||||
- view controllers or ViewModels currently contain push/present calls
|
||||
|
||||
## Validating User-Requested Architectures
|
||||
|
||||
When the user pre-selects an architecture, validate it before finalizing:
|
||||
|
||||
1. Check fit across:
|
||||
- UI stack (SwiftUI/UIKit/mixed)
|
||||
- feature complexity and state model needs
|
||||
- effect orchestration requirements
|
||||
- team familiarity and dependency tolerance
|
||||
- alignment with existing codebase conventions
|
||||
2. Decide whether the request is a `fit` or a `mismatch`.
|
||||
3. Respond based on the result:
|
||||
- `fit`: proceed with requested architecture
|
||||
- `mismatch`: recommend closest-fit alternative and explain why
|
||||
|
||||
If the user insists on a mismatched choice, proceed with the requested architecture but include a risk-mitigation plan.
|
||||
|
||||
## Combining Architectures
|
||||
|
||||
Some projects use multiple patterns. Common valid combinations:
|
||||
|
||||
- **MVVM + Reactive**: MVVM structure with Combine/Rx pipelines inside ViewModels
|
||||
- **Clean Architecture + MVVM**: Clean layers for domain/data, MVVM for presentation
|
||||
- **Clean Architecture + TCA**: Clean layers for domain/data, TCA for feature presentation
|
||||
- **VIPER + Reactive**: VIPER module structure with reactive Interactors
|
||||
- **MVVM + Coordinator**: MVVM for screen-level state, Coordinator for navigation flows
|
||||
- **MVP + Coordinator**: MVP for presentation logic, Coordinator for navigation and routing
|
||||
- **Clean Architecture + MVP**: Clean layers for domain/data, MVP for presentation
|
||||
|
||||
When combining, clarify which pattern governs which layer and keep boundaries consistent.
|
||||
|
||||
## Recommendation Format
|
||||
|
||||
When recommending:
|
||||
|
||||
1. Name one pattern and provide a fit result (`fit` or `mismatch`).
|
||||
2. Give 1-2 concise reasons grounded in user constraints.
|
||||
3. Cite the reference file.
|
||||
4. If `mismatch`, include the closest-fit alternative and one trade-off.
|
||||
5. Apply the selected playbook to the user’s feature.
|
||||
406
.agents/skills/swift-architecture-skill/references/tca.md
Normal file
406
.agents/skills/swift-architecture-skill/references/tca.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# TCA Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference for strict unidirectional flow, strong composition, and `TestStore`-driven testing.
|
||||
|
||||
## Mental Model
|
||||
|
||||
```text
|
||||
View -> store.send(Action)
|
||||
Reducer(State, Action) -> state mutation + Effect<Action>
|
||||
Effect emits Action -> reducer
|
||||
```
|
||||
|
||||
Core expectations:
|
||||
- value-based state
|
||||
- reducer-driven decisions
|
||||
- isolated side effects via effects
|
||||
- dependency injection through TCA dependencies
|
||||
- feature composition with scoped reducers
|
||||
|
||||
## Canonical Feature Shape
|
||||
|
||||
Prefer modern TCA with `@Reducer` and `@ObservableState`.
|
||||
|
||||
```swift
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct CounterFeature {
|
||||
enum CancelID { case fact }
|
||||
|
||||
enum FactError: Error, Equatable {
|
||||
case unavailable
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var count = 0
|
||||
var isLoading = false
|
||||
@Presents var alert: AlertState<Action.Alert>?
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case incrementTapped
|
||||
case decrementTapped
|
||||
case factButtonTapped
|
||||
case factResponse(Result<String, FactError>)
|
||||
case alert(PresentationAction<Alert>)
|
||||
|
||||
enum Alert: Equatable {}
|
||||
}
|
||||
|
||||
@Dependency(\.numberFact) var numberFact
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .incrementTapped:
|
||||
state.count += 1
|
||||
return .none
|
||||
|
||||
case .decrementTapped:
|
||||
state.count -= 1
|
||||
return .none
|
||||
|
||||
case .factButtonTapped:
|
||||
state.isLoading = true
|
||||
let n = state.count
|
||||
return .run { send in
|
||||
do {
|
||||
let fact = try await numberFact.fetch(n)
|
||||
await send(.factResponse(.success(fact)))
|
||||
} catch is CancellationError {
|
||||
// Cancellation is expected when a new request replaces this one.
|
||||
} catch {
|
||||
await send(.factResponse(.failure(.unavailable)))
|
||||
}
|
||||
}
|
||||
.cancellable(id: CancelID.fact, cancelInFlight: true)
|
||||
|
||||
case .factResponse(.success(let fact)):
|
||||
state.isLoading = false
|
||||
state.alert = AlertState { TextState(fact) }
|
||||
return .none
|
||||
|
||||
case .factResponse(.failure):
|
||||
state.isLoading = false
|
||||
state.alert = AlertState { TextState("Could not load fact.") }
|
||||
return .none
|
||||
|
||||
case .alert:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
.ifLet(\.$alert, action: \.alert)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View Integration
|
||||
|
||||
Rules:
|
||||
- send actions from the view
|
||||
- never mutate business state directly in the view
|
||||
- observe the smallest practical state slice
|
||||
|
||||
### Modern Pattern (TCA 1.7+ with `@ObservableState`)
|
||||
|
||||
With `@ObservableState`, views access store properties directly — no `WithViewStore` needed.
|
||||
|
||||
```swift
|
||||
struct CounterView: View {
|
||||
@Bindable var store: StoreOf<CounterFeature>
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("Count: \(store.count)")
|
||||
Button("+") { store.send(.incrementTapped) }
|
||||
Button("-") { store.send(.decrementTapped) }
|
||||
Button("Fact") { store.send(.factButtonTapped) }
|
||||
if store.isLoading { ProgressView() }
|
||||
}
|
||||
.alert($store.scope(state: \.alert, action: \.alert))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Legacy Pattern (TCA < 1.7 with `WithViewStore`)
|
||||
|
||||
```swift
|
||||
struct CounterView: View {
|
||||
let store: StoreOf<CounterFeature>
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
VStack {
|
||||
Text("Count: \(viewStore.count)")
|
||||
Button("+") { viewStore.send(.incrementTapped) }
|
||||
Button("-") { viewStore.send(.decrementTapped) }
|
||||
Button("Fact") { viewStore.send(.factButtonTapped) }
|
||||
if viewStore.isLoading { ProgressView() }
|
||||
}
|
||||
.alert(store: store.scope(state: \.alert, action: \.alert))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
UIKit guidance:
|
||||
- keep a store in the view controller
|
||||
- subscribe to state changes from the store
|
||||
- centralize rendering in one method
|
||||
|
||||
Concrete UIKit pattern:
|
||||
|
||||
```swift
|
||||
import ComposableArchitecture
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class CounterViewController: UIViewController {
|
||||
private let viewStore: ViewStoreOf<CounterFeature>
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(store: StoreOf<CounterFeature>) {
|
||||
self.viewStore = ViewStore(store, observe: { $0 })
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { return nil }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
viewStore.publisher
|
||||
.sink { [weak self] state in
|
||||
self?.render(state)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@objc private func incrementTapped() {
|
||||
viewStore.send(.incrementTapped)
|
||||
}
|
||||
|
||||
private func render(_ state: CounterFeature.State) {
|
||||
title = "Count: \(state.count)"
|
||||
// Render labels/buttons/loading from state only.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
Use `Scope` for parent-child composition.
|
||||
|
||||
```swift
|
||||
@Reducer
|
||||
struct AppFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var counter = CounterFeature.State()
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case counter(CounterFeature.Action)
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.counter, action: \.counter) {
|
||||
CounterFeature()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `IdentifiedArrayOf` and `forEach` for collections with stable identity.
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
- keep dependency surfaces small and capability-focused
|
||||
- inject via `@Dependency`
|
||||
- never place dependencies in state
|
||||
- avoid singleton calls in reducers
|
||||
|
||||
```swift
|
||||
struct NumberFactClient {
|
||||
var fetch: @Sendable (Int) async throws -> String
|
||||
}
|
||||
|
||||
extension NumberFactClient: DependencyKey {
|
||||
static let liveValue = Self(fetch: { number in
|
||||
"\(number) is a good number."
|
||||
})
|
||||
|
||||
static let testValue = Self(fetch: { _ in
|
||||
"Test fact"
|
||||
})
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var numberFact: NumberFactClient {
|
||||
get { self[NumberFactClient.self] }
|
||||
set { self[NumberFactClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Effects and Concurrency
|
||||
|
||||
Use `.run` for async work and route results back as actions.
|
||||
|
||||
For re-entrant work, add cancellation (`.cancellable(id:cancelInFlight:)`) and map failures to explicit actions.
|
||||
If cancellation is not enough, add request versioning.
|
||||
|
||||
## Navigation Pattern
|
||||
|
||||
Model navigation in state and drive it through actions.
|
||||
|
||||
Common shapes:
|
||||
- `@Presents var alert: AlertState<Action.Alert>?`
|
||||
- `destination: Destination.State?`
|
||||
- Attach a matching `.ifLet` reducer for each presentation action (`alert`, `destination`, etc.).
|
||||
|
||||
Keep navigation decisions in reducers and keep views declarative.
|
||||
|
||||
## Testing with `TestStore`
|
||||
|
||||
Use `TestStore` for deterministic action/state assertions.
|
||||
Cover success, failure, and cancellation paths in async effects.
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
import ComposableArchitecture
|
||||
|
||||
@MainActor
|
||||
final class CounterFeatureTests: XCTestCase {
|
||||
func testIncrement() async {
|
||||
let store = TestStore(initialState: CounterFeature.State()) {
|
||||
CounterFeature()
|
||||
}
|
||||
|
||||
await store.send(.incrementTapped) {
|
||||
$0.count = 1
|
||||
}
|
||||
}
|
||||
|
||||
func testFactSuccess() async {
|
||||
let store = TestStore(initialState: CounterFeature.State()) {
|
||||
CounterFeature()
|
||||
} withDependencies: {
|
||||
$0.numberFact.fetch = { _ in "42 is great" }
|
||||
}
|
||||
|
||||
await store.send(.factButtonTapped) {
|
||||
$0.isLoading = true
|
||||
}
|
||||
await store.receive(.factResponse(.success("42 is great"))) {
|
||||
$0.isLoading = false
|
||||
$0.alert = AlertState { TextState("42 is great") }
|
||||
}
|
||||
}
|
||||
|
||||
func testFactFailure() async {
|
||||
let store = TestStore(initialState: CounterFeature.State()) {
|
||||
CounterFeature()
|
||||
} withDependencies: {
|
||||
$0.numberFact.fetch = { _ in throw CounterFeature.FactError.unavailable }
|
||||
}
|
||||
|
||||
await store.send(.factButtonTapped) {
|
||||
$0.isLoading = true
|
||||
}
|
||||
await store.receive(.factResponse(.failure(.unavailable))) {
|
||||
$0.isLoading = false
|
||||
$0.alert = AlertState { TextState("Could not load fact.") }
|
||||
}
|
||||
}
|
||||
|
||||
func testFactCancellation_replacesInFlightRequest() async {
|
||||
let clock = TestClock()
|
||||
|
||||
actor Sequence {
|
||||
var values = ["first", "second"]
|
||||
func next() -> String { values.removeFirst() }
|
||||
}
|
||||
let sequence = Sequence()
|
||||
|
||||
let store = TestStore(initialState: CounterFeature.State()) {
|
||||
CounterFeature()
|
||||
} withDependencies: {
|
||||
$0.numberFact.fetch = { _ in
|
||||
let value = await sequence.next()
|
||||
try await clock.sleep(for: .seconds(1))
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
await store.send(.factButtonTapped) {
|
||||
$0.isLoading = true
|
||||
}
|
||||
await store.send(.factButtonTapped)
|
||||
|
||||
await clock.advance(by: .seconds(1))
|
||||
|
||||
await store.receive(.factResponse(.success("second"))) {
|
||||
$0.isLoading = false
|
||||
$0.alert = AlertState { TextState("second") }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. Massive feature with no composition:
|
||||
- Smell: giant reducer handling unrelated domains.
|
||||
- Fix: split into child reducers and compose via `Scope`.
|
||||
|
||||
2. Reference types in state:
|
||||
- Smell: class instances or shared mutable collections in state.
|
||||
- Fix: keep state value-based and equatable.
|
||||
|
||||
3. Business work in views:
|
||||
- Smell: view calls services or transforms domain data.
|
||||
- Fix: move logic to reducer/effects and expose render-ready state.
|
||||
|
||||
4. Side effects directly in reducer:
|
||||
- Smell: analytics/network calls inline without effect boundary.
|
||||
- Fix: route through dependencies and effects.
|
||||
|
||||
5. Duplicate state outside store:
|
||||
- Smell: local `@State` mirrors store state.
|
||||
- Fix: keep single source of truth in store.
|
||||
|
||||
6. Over-observing large state:
|
||||
- Smell: broad observation triggers unnecessary re-renders.
|
||||
- Fix: observe scoped state and split view/store boundaries.
|
||||
|
||||
7. Missing cancellation:
|
||||
- Smell: overlapping effects overwrite current intent.
|
||||
- Fix: use `.cancellable(id:cancelInFlight:)` and request IDs when needed.
|
||||
|
||||
## When to Prefer TCA
|
||||
|
||||
Prefer TCA when:
|
||||
- app has many stateful workflows
|
||||
- test determinism is critical
|
||||
- composition and modular scaling are required
|
||||
- effect cancellation correctness matters
|
||||
|
||||
Prefer MVVM or lighter MVI variants when:
|
||||
- app is small and unlikely to grow
|
||||
- team is not ready for UDF discipline
|
||||
- feature speed and low ceremony are prioritized
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- State is value-based and equatable.
|
||||
- Reducer avoids direct side effects.
|
||||
- Dependencies are injected and overrideable in tests.
|
||||
- Effects have cancellation strategy where needed.
|
||||
- Features compose with `Scope`/`forEach`.
|
||||
- Navigation is modeled in state.
|
||||
- Tests cover success, failure, and cancellation flows.
|
||||
- Views render and send actions only.
|
||||
491
.agents/skills/swift-architecture-skill/references/viper.md
Normal file
491
.agents/skills/swift-architecture-skill/references/viper.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# VIPER Playbook (Swift + SwiftUI/UIKit)
|
||||
|
||||
Use this reference when strict feature-level separation is needed, especially in large or legacy UIKit codebases.
|
||||
|
||||
## Core Components
|
||||
|
||||
- View: render UI and forward user actions
|
||||
- Interactor: execute business logic and coordinate data access
|
||||
- Presenter: transform entities into display-ready output and control view state
|
||||
- Entity: domain models used by the feature
|
||||
- Router: navigation and module assembly
|
||||
|
||||
Expected interaction:
|
||||
|
||||
```text
|
||||
View -> Presenter -> Interactor -> Repository/Service -> Interactor -> Presenter -> View
|
||||
Presenter -> Router (navigation)
|
||||
```
|
||||
|
||||
## Canonical Feature Layout
|
||||
|
||||
```text
|
||||
Feature/
|
||||
View/
|
||||
Presenter/
|
||||
Interactor/
|
||||
Entity/
|
||||
Router/
|
||||
```
|
||||
|
||||
Keep one VIPER module per feature to prevent cross-feature leakage.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
### View
|
||||
|
||||
- Render data provided by Presenter.
|
||||
- Forward user inputs (`didTap...`, `didAppear`, text changes).
|
||||
- Avoid direct service/repository access.
|
||||
- In SwiftUI, use an adapter (`@Observable` on iOS 17+ or `ObservableObject` when Combine/UIKit interop is needed) that forwards to Presenter.
|
||||
|
||||
### Presenter
|
||||
|
||||
- Own presentation flow for the feature.
|
||||
- Ask Interactor for business results.
|
||||
- Map entities to view models/display strings.
|
||||
- Call Router for navigation.
|
||||
|
||||
### Interactor
|
||||
|
||||
- Execute business rules and use cases.
|
||||
- Call repositories/services through protocols.
|
||||
- Return domain results to Presenter.
|
||||
- Avoid direct view or navigation concerns.
|
||||
|
||||
### Router
|
||||
|
||||
- Perform navigation transitions.
|
||||
- Build and wire module dependencies.
|
||||
|
||||
### Entity
|
||||
|
||||
- Represent domain data and business invariants.
|
||||
- Avoid UI and framework coupling where possible.
|
||||
- Keep display formatting out of `Entity`; Presenter maps entity -> display model.
|
||||
|
||||
```swift
|
||||
struct User: Equatable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let isPremium: Bool
|
||||
}
|
||||
|
||||
struct ProfileViewData: Equatable {
|
||||
let displayName: String
|
||||
let badgeText: String?
|
||||
}
|
||||
|
||||
extension ProfileViewData {
|
||||
init(user: User) {
|
||||
self.displayName = user.name
|
||||
self.badgeText = user.isPremium ? "Premium" : nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Wiring Pattern
|
||||
|
||||
Use boundary protocols and directional references.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol ProfileView: AnyObject {
|
||||
func showLoading(_ isLoading: Bool)
|
||||
func show(profile: ProfileViewData)
|
||||
func showError(message: String)
|
||||
}
|
||||
|
||||
protocol ProfileInteracting {
|
||||
func loadUser() async throws -> User
|
||||
}
|
||||
|
||||
protocol ProfileRouting {
|
||||
func showSettings()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProfilePresenter {
|
||||
weak var view: ProfileView?
|
||||
private let interactor: ProfileInteracting
|
||||
private let router: ProfileRouting
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var latestLoadRequestID: UUID?
|
||||
|
||||
init(interactor: ProfileInteracting, router: ProfileRouting) {
|
||||
self.interactor = interactor
|
||||
self.router = router
|
||||
}
|
||||
|
||||
func load() {
|
||||
let requestID = UUID()
|
||||
latestLoadRequestID = requestID
|
||||
loadTask?.cancel()
|
||||
view?.showLoading(true)
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
let user = try await interactor.loadUser()
|
||||
try Task.checkCancellation()
|
||||
guard latestLoadRequestID == requestID else { return }
|
||||
view?.show(profile: ProfileViewData(user: user))
|
||||
} catch is CancellationError {
|
||||
// Cancelled by a newer load request.
|
||||
} catch {
|
||||
guard latestLoadRequestID == requestID else { return }
|
||||
view?.showError(message: "Failed to load profile. Please try again.")
|
||||
}
|
||||
guard latestLoadRequestID == requestID else { return }
|
||||
view?.showLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
func didTapSettings() {
|
||||
router.showSettings()
|
||||
}
|
||||
|
||||
deinit {
|
||||
loadTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `view` weak to avoid retain cycles.
|
||||
Keep presenter/view updates on the main actor so UI calls are thread-safe.
|
||||
|
||||
## Assembly Guidance
|
||||
|
||||
Create modules via Router/Assembly factory:
|
||||
- instantiate View, Presenter, Interactor, Router
|
||||
- inject protocols, not concrete global singletons
|
||||
- set references once during build
|
||||
|
||||
This centralizes wiring and reduces circular dependency mistakes.
|
||||
|
||||
```swift
|
||||
enum ProfileModule {
|
||||
static func build(
|
||||
userRepository: UserRepository,
|
||||
navigationController: UINavigationController
|
||||
) -> UIViewController {
|
||||
let interactor = ProfileInteractor(repository: userRepository)
|
||||
let router = ProfileRouter(navigationController: navigationController)
|
||||
let presenter = ProfilePresenter(interactor: interactor, router: router)
|
||||
let viewController = ProfileViewController(presenter: presenter)
|
||||
presenter.view = viewController
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- keep the factory method as the single entry point for module creation
|
||||
- inject external dependencies (repositories, services) from the caller
|
||||
- set weak back-references (e.g., `presenter.view`) after construction
|
||||
|
||||
SwiftUI integration option:
|
||||
- keep Presenter/Interactor/Router unchanged
|
||||
- wrap SwiftUI feature view in `UIHostingController`
|
||||
- bridge Presenter output through a small adapter object
|
||||
- for pure SwiftUI apps, inject a SwiftUI router object instead of requiring `UINavigationController`
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ProfileViewAdapter: ObservableObject, ProfileView {
|
||||
@Published private(set) var name = ""
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
private let presenter: ProfilePresenter
|
||||
|
||||
init(presenter: ProfilePresenter) {
|
||||
self.presenter = presenter
|
||||
}
|
||||
|
||||
func showLoading(_ isLoading: Bool) {
|
||||
self.isLoading = isLoading
|
||||
}
|
||||
|
||||
func show(profile: ProfileViewData) {
|
||||
self.name = profile.displayName
|
||||
self.errorMessage = nil
|
||||
}
|
||||
|
||||
func showError(message: String) {
|
||||
self.errorMessage = message
|
||||
}
|
||||
|
||||
func load() { presenter.load() }
|
||||
func didTapSettings() { presenter.didTapSettings() }
|
||||
}
|
||||
|
||||
struct ProfileScreen: View {
|
||||
@ObservedObject var adapter: ProfileViewAdapter
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(adapter.name)
|
||||
if adapter.isLoading { ProgressView() }
|
||||
if let errorMessage = adapter.errorMessage {
|
||||
Text(errorMessage)
|
||||
}
|
||||
Button("Settings") { adapter.didTapSettings() }
|
||||
}
|
||||
.task { adapter.load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum ProfileModuleSwiftUI {
|
||||
static func build(
|
||||
userRepository: UserRepository,
|
||||
navigationController: UINavigationController
|
||||
) -> UIViewController {
|
||||
let interactor = ProfileInteractor(repository: userRepository)
|
||||
let router = ProfileRouter(navigationController: navigationController)
|
||||
let presenter = ProfilePresenter(interactor: interactor, router: router)
|
||||
let adapter = ProfileViewAdapter(presenter: presenter)
|
||||
presenter.view = adapter
|
||||
return UIHostingController(rootView: ProfileScreen(adapter: adapter))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pure SwiftUI app option (no `UINavigationController`):
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
enum AppDestination: Hashable {
|
||||
case settings
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppRouter {
|
||||
var path: [AppDestination] = []
|
||||
|
||||
func push(_ destination: AppDestination) {
|
||||
path.append(destination)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProfileSwiftUIRouter: ProfileRouting {
|
||||
private let appRouter: AppRouter
|
||||
|
||||
init(appRouter: AppRouter) {
|
||||
self.appRouter = appRouter
|
||||
}
|
||||
|
||||
func showSettings() {
|
||||
appRouter.push(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
enum ProfileModulePureSwiftUI {
|
||||
@MainActor
|
||||
static func build(
|
||||
userRepository: UserRepository,
|
||||
appRouter: AppRouter
|
||||
) -> ProfileScreen {
|
||||
let interactor = ProfileInteractor(repository: userRepository)
|
||||
let router = ProfileSwiftUIRouter(appRouter: appRouter)
|
||||
let presenter = ProfilePresenter(interactor: interactor, router: router)
|
||||
let adapter = ProfileViewAdapter(presenter: presenter)
|
||||
presenter.view = adapter
|
||||
return ProfileScreen(adapter: adapter)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
At app root, bind the shared router path to `NavigationStack`:
|
||||
|
||||
```swift
|
||||
struct AppRootView: View {
|
||||
@State private var appRouter = AppRouter()
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
NavigationStack(path: $appRouter.path) {
|
||||
ProfileModulePureSwiftUI.build(
|
||||
userRepository: LiveUserRepository(),
|
||||
appRouter: appRouter
|
||||
)
|
||||
.navigationDestination(for: AppDestination.self) { destination in
|
||||
switch destination {
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Concurrency and Cancellation
|
||||
|
||||
When Presenter coordinates async work, track active tasks and cancel stale requests. The `ProfilePresenter` shown in the Wiring Pattern section above already implements the full cancellation strategy — it holds a `loadTask: Task<Void, Never>?`, a `latestLoadRequestID: UUID?`, and handles `CancellationError` explicitly to guard against stale UI updates.
|
||||
|
||||
Rules:
|
||||
- cancel in-flight tasks before issuing new requests
|
||||
- handle `CancellationError` explicitly to avoid stale UI updates
|
||||
- gate UI updates by request identity so only the latest request can update view state
|
||||
- cancel all tasks on module teardown
|
||||
- keep presenter intent methods synchronous (`func load()`), and manage async tasks internally
|
||||
|
||||
## Anti-Patterns and Fixes
|
||||
|
||||
1. Massive Presenter:
|
||||
- Smell: presenter contains business logic, formatting, networking, and navigation details.
|
||||
- Fix: move business logic to Interactor and formatting helpers; keep Presenter orchestration-focused.
|
||||
|
||||
2. Interactor performing navigation:
|
||||
- Smell: interactor directly pushes/presents screens.
|
||||
- Fix: route navigation through Router called by Presenter.
|
||||
|
||||
3. Circular dependencies and strong cycles:
|
||||
- Smell: View <-> Presenter <-> Router retain each other strongly.
|
||||
- Fix: use boundary protocols and weak references where required.
|
||||
|
||||
4. View doing business work:
|
||||
- Smell: View transforms data or calls services directly.
|
||||
- Fix: move logic into Presenter/Interactor.
|
||||
|
||||
5. Router containing business logic:
|
||||
- Smell: Router decides domain outcomes.
|
||||
- Fix: keep Router limited to navigation and assembly.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Prioritize isolated tests per component:
|
||||
- Presenter tests with mocked View/Interactor/Router
|
||||
- Interactor tests with mocked repositories/services
|
||||
- Router tests for navigation triggers where feasible
|
||||
|
||||
Testing rules:
|
||||
- assert interactions and outputs, not concrete implementations
|
||||
- avoid network in unit tests
|
||||
- verify presenter handles success and failure states
|
||||
- verify Presenter-to-View error contract (`showError(message:)`) for failure paths
|
||||
- test cancellation behavior when a newer load replaces an in-flight request
|
||||
- keep async tests deterministic with controlled stubs/clocks (avoid sleeps)
|
||||
|
||||
Use the cancellation-aware presenter from the "Concurrency and Cancellation" section for cancellation-path tests.
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class MockProfileView: ProfileView {
|
||||
var shownName: String?
|
||||
var shownError: String?
|
||||
var isLoading = false
|
||||
|
||||
func showLoading(_ isLoading: Bool) { self.isLoading = isLoading }
|
||||
|
||||
func show(profile: ProfileViewData) {
|
||||
shownName = profile.displayName
|
||||
}
|
||||
|
||||
func showError(message: String) {
|
||||
shownError = message
|
||||
}
|
||||
}
|
||||
|
||||
struct StubProfileInteractor: ProfileInteracting {
|
||||
var load: () async throws -> User
|
||||
func loadUser() async throws -> User { try await load() }
|
||||
}
|
||||
|
||||
final class SpyProfileRouter: ProfileRouting {
|
||||
var didShowSettings = false
|
||||
func showSettings() { didShowSettings = true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProfilePresenterTests: XCTestCase {
|
||||
func test_load_success_showsUserName() async {
|
||||
let user = User(id: UUID(), name: "Alice", isPremium: false)
|
||||
let view = MockProfileView()
|
||||
let presenter = ProfilePresenter(
|
||||
interactor: StubProfileInteractor(load: { user }),
|
||||
router: SpyProfileRouter()
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownName, "Alice")
|
||||
}
|
||||
|
||||
func test_load_failure_showsError() async {
|
||||
let view = MockProfileView()
|
||||
let presenter = ProfilePresenter(
|
||||
interactor: StubProfileInteractor(load: { throw TestError.notFound }),
|
||||
router: SpyProfileRouter()
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownError, "Failed to load profile. Please try again.")
|
||||
}
|
||||
|
||||
func test_didTapSettings_routesToSettings() {
|
||||
let router = SpyProfileRouter()
|
||||
let presenter = ProfilePresenter(
|
||||
interactor: StubProfileInteractor(load: { User(id: UUID(), name: "", isPremium: false) }),
|
||||
router: router
|
||||
)
|
||||
|
||||
presenter.didTapSettings()
|
||||
|
||||
XCTAssertTrue(router.didShowSettings)
|
||||
}
|
||||
|
||||
func test_load_cancellation_doesNotOverwriteExistingName() async {
|
||||
let view = MockProfileView()
|
||||
view.shownName = "Current"
|
||||
let presenter = ProfilePresenter(
|
||||
interactor: StubProfileInteractor(load: { throw CancellationError() }),
|
||||
router: SpyProfileRouter()
|
||||
)
|
||||
presenter.view = view
|
||||
|
||||
presenter.load()
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertEqual(view.shownName, "Current")
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error { case notFound }
|
||||
```
|
||||
|
||||
## When to Prefer VIPER
|
||||
|
||||
Prefer VIPER when:
|
||||
- multiple teams need independently owned feature modules with explicit boundaries
|
||||
- strict role separation reduces architecture drift in long-lived codebases
|
||||
- interactor-level business rules must be testable without booting UI screens
|
||||
- modular compilation and clear dependency direction are high priorities
|
||||
- UIKit-heavy codebase benefits from router-driven assembly/navigation
|
||||
|
||||
Prefer lighter patterns when:
|
||||
- app is small or prototyping quickly
|
||||
- ceremony cost outweighs boundary/testability benefits
|
||||
|
||||
Compared with organized MVVM, VIPER usually adds more setup but enforces role boundaries more strongly at scale, especially when teams and modules are decoupled.
|
||||
|
||||
## PR Review Checklist
|
||||
|
||||
- Component responsibilities are respected (View/Interactor/Presenter/Router separated).
|
||||
- Presenter does not own business logic implementation details.
|
||||
- Interactor does not navigate.
|
||||
- Router handles only navigation and module assembly.
|
||||
- Boundary protocols avoid concrete coupling.
|
||||
- Retain cycles are prevented with weak references where needed.
|
||||
- Tests cover presenter orchestration and interactor business rules.
|
||||
Reference in New Issue
Block a user