| name | ios-app-architecture |
| description | Choose and implement the right iOS/macOS app architecture (Swift 6 / iOS 26 era). A decision compass for picking a stack (MVC / MVVM+Coordinator / MVVM+Router / Clean / VIPER / MVI / TCA) plus implementation guidance — ViewModels, Use Cases, Repository, reducers, navigation (Coordinator vs NavigationStack/Router), dependency injection (manual / Factory / Swinject), SPM modularization, and @MainActor/actor/Task concurrency placement. Use when asked "which architecture should I use", "MVVM or Clean", "how do I structure this iOS app", "set up Clean Architecture", "add a Coordinator", "refactor this Massive View Controller", or when starting a new iOS/macOS project and the stack is undecided. |
iOS App Architecture
Pick one architecture per project, write the choice down, and follow it. This
skill is a compass first, cookbook second: the matrix below routes you to a
stack; the reference files give the full implementation of each.
Golden rule: architecture fits a project's current size and lifetime, not
its imagined future. The cost of a ViewModel is one file; the cost of a Massive
View Controller is six months. The cost of Clean+VIPER+SPM+Swinject on a
5-screen utility is wasted weeks and obscured intent. Match the row.
When to run this skill
- New project and the stack is undecided → run the compass once, record the result.
Before scaffolding, run the 5-question intake gate (Apple-account type, backend?,
auth?, payments?, bundle identifier) — references/new-project-setup.md. Each answer
gates what you generate (e.g. a free account ⇒ no SIWA/push entitlements).
- Major refactor with a concrete trigger (team grew past 3 devs, an explicit
domain emerged, compile time / merge conflicts hurt, "impossible state" bugs
recur) → re-run the compass for the affected scope.
- Implementing within an already-chosen stack → skip the compass, jump to the
matching reference file (
references/pattern-*.md).
If the stack is already decided and the user is not refactoring, don't run the
compass — follow the chosen pattern's reference.
The decision compass
Fast path (skip the questionnaire)
If one of these is clearly true from context, recommend directly:
| Context | Recommend |
|---|
| Throwaway prototype, 1–2 weeks | MVC + manual DI; skip Coordinator |
| Greenfield SwiftUI, iOS 17+, 1–2 devs | MVVM + Router with @Observable; manual DI |
| Existing UIKit production app, stable | MVVM + Coordinator (preserves UIKit idioms) |
| Existing UIKit app, new features in SwiftUI | Hybrid: MVVM + Coordinator at top, SwiftUI islands with Router |
| Multi-platform iOS + macOS, shared business logic | Clean Architecture (shared Domain) + per-platform Presentation |
| SwiftUI-only, non-trivial state machine, want unidirectional flow without TCA | MVI |
| SwiftUI-only, team already fluent with TCA, rich state + exhaustive tests required | TCA |
If none fits clearly, ask the five axes.
Five input axes
Get a real answer for each — don't infer from project name or vibes.
| Axis | Spectrum | Why it matters |
|---|
| Team size | solo → 2–3 → 4+ | More people → stronger boundaries to avoid collisions |
| Lifetime | weeks → months → years | Longer life → invest in tests, layers, conventions |
| Domain complexity | CRUD/forms → multiple flows → rich business rules | Rich domain → explicit Use Cases keep logic out of the UI |
| UI framework | UIKit / SwiftUI / AppKit / hybrid | Drives navigation (Coordinator vs NavigationStack+Router) and binding tools |
| Testing rigor | smoke/manual → unit on logic → unit + integration + UI | High rigor + rich domain → pivots toward Clean (pure-Swift testable Use Cases) |
- Domain complexity and screen count are independent. A 30-screen catalog with
simple CRUD stays MVVM+Coordinator; 4 screens with banking rules may need Clean.
- Don't ask team familiarity for default-track stacks (MVC/MVVM/Clean). Do
ask for non-default tracks (VIPER, TCA) — those require existing fluency or the
choice is wrong regardless of other axes.
Decision matrix
Find the row that best matches reality. Thresholds are heuristics, not boundaries —
at the edges, apply the When-in-Doubt defaults below.
| Scenario | Stack | Navigation | Why |
|---|
| Solo, weeks–months, simple CRUD, ≤5 screens | MVC | VC push/present | One file per screen; tests target Models |
| Solo/pair, months–years, modest logic, UIKit | MVVM + Coordinator | Coordinator | ViewModel testable in isolation; navigation out of VCs |
| Solo/pair, months–years, modest logic, SwiftUI iOS 16+ | MVVM + Router | NavigationStack + Path | @Observable VM + state-driven nav; no Coordinator boilerplate |
| Existing UIKit + new SwiftUI features | Hybrid: MVVM + Coordinator + Router | Coordinator outer, Router per SwiftUI island | Old code stays UIKit; new code is native SwiftUI |
| 2–3 devs, years, rich domain, must unit-test business rules | Clean Architecture | Coordinator / Router | Use Cases are pure Swift — testable without UIKit |
| 4+ devs, parallel feature work, strict module ownership | Clean + SPM modules | Coordinator / Router | Cross-team deps become compile errors, not merge conflicts |
| macOS utility / settings-style app | MVC (AppKit) or MVVM (SwiftUI) | Window / Sheet | AppKit MVC for heavy menu/window APIs; SwiftUI MVVM for forms |
| Multi-platform iOS + macOS, shared logic | Clean Architecture | Per-platform Presentation | Domain/Data shared via SPM; Presentation per platform |
| Legacy team fluent in VIPER, large existing codebase | VIPER (modernized to async/await) | Router | Only where the team is already fluent; else MVVM |
| SwiftUI/UIKit+Combine, non-trivial state machine, unidirectional without TCA's curve | MVI (Pure or MVVM+Single State) | NavigationStack + Path / Coordinator | Single State value, pure reducer, lighter than TCA |
| SwiftUI-only, team fluent with TCA/Elm/Redux, rich state machines, exhaustive testing | TCA | @Presents / StackState | Reducer composition + TestStore; non-default track — pick consciously |
One row, not a blend. A "Clean-MVC" hybrid is almost always Massive View
Controller in disguise. The Hybrid row is the one legitimate mix — same
patterns across two UI frameworks, not different patterns per feature.
When in doubt
| Tension | Default to |
|---|
| MVC vs MVVM | MVVM. A ViewModel is one file; a Massive VC is six months |
| Clean vs MVVM | MVVM. Migrate to Clean later by extracting Use Cases — additive, no rewrite |
| Coordinator vs Router on hybrid UIKit+SwiftUI | Coordinator at the top; Router inside SwiftUI islands |
| "Should we modularize?" | Not yet. One package, multiple folders, until 2+ devs collide or compile time hurts |
| RxSwift vs Combine on a new project | Combine. RxSwift only if existing code already uses it |
| Manual DI vs Factory vs Swinject | Manual until ~10 services; then Factory for SwiftUI-first; Swinject only for runtime autoregister / name lookup / legacy |
| TCA? | Only when SwiftUI-only and team already fluent and exhaustive reducer tests pay off. Else MVVM. Never "to future-proof" |
Record the decision
Architecture chosen without writing it down drifts within a sprint. After the
compass, write the choice into the project's guidance file (CLAUDE.md /
README.md / an ARCHITECTURE.md) as one short block:
## Architecture
- Pattern: MVVM + Coordinator
- UI: UIKit
- DI: Manual (AppDependencyContainer)
- Binding: Combine
- Modularization: single package (revisit at 2+ devs)
<!-- Chosen 2026-06: solo, ~12 mo, modest logic, UIKit -> MVVM+Coordinator -->
If the user overrides the recommendation, record their choice and the matrix
objection next to it — risks must stay visible.
Implementation: which reference to read
Each stack maps to a set of reference files. Read the one(s) for the chosen stack;
the cross-cutting files apply to every stack.
Cross-cutting decisions are parallel, not derived
Architecture choice does not decide DI, navigation, modularization, or the
error/network/persistence layers. Decide each independently:
- DI is a separate axis: manual graph → Factory → Swinject. Domain/Use Cases
never import the DI framework. See references/di-and-modularization.md.
- Navigation is chosen by UI framework, not architecture: Coordinator (UIKit)
vs
NavigationStack + @Observable Router (SwiftUI). TCA owns its own
navigation. See references/navigation.md.
- Modularization (SPM) starts when 2+ devs collide or 3+ features share code
or compile time hurts — not at day one.
- Error / network / persistence layers are needed regardless of pattern. Set
up
error-architecture from day one (cheap now, expensive to retrofit). See
references/cross-cutting-layers.md.
- Binding framework (Combine / RxSwift /
@Observable) is a tool used inside
an architecture, not an architecture itself. "We use Combine → MVVM-C" is a
category error.
- App Intents (exposing app actions/screens to Siri / Spotlight / Shortcuts /
widgets) is its own integration layer over your domain services — intents are thin
bridges, never a logic home, and navigation stays in SwiftUI via
onAppIntentExecution, not in perform(). The full framework how-to lives in the
dedicated app-intents skill; navigation.md has the navigation-entry hook.
- Keeping
ARCHITECTURE.md current (optional): after a session, run
git diff --name-only HEAD (+ --cached), and for each changed .swift make
targeted edits only to the doc — never a full rewrite. Document ViewModels,
Services, API clients, significant Models, routing enums, config keys, backend
routes; list View files by directory only, never individually. No Swift changed
→ don't touch the file.
Concurrency placement (the 30-second version)
The full rules are in references/concurrency-placement.md. The decisions that
resolve 90% of questions:
WHERE IS @MainActor?
YES: View, ViewController, ViewModel, Presenter, Coordinator, Router, app-scoped UI state
NO: UseCase, Interactor, stateless Service, Repository façade, APIClient, Logger
actor (custom): a Service with mutable state shared across concurrent callers
WHO OWNS THE TASK?
SwiftUI: .task { } modifier — auto-cancelled on disappear
UIKit ViewModel: stored `Task<Void, Never>?`, cancelled in deinit + viewWillDisappear
TCA: Effect.run + .cancellable(id:)
Outlives screen: app-scoped Service injected via DI (not Task.detached)
CANCELLATION propagates DOWN through `await` automatically. Don't break it:
- never catch CancellationError and rethrow it as a domain error
- never Task.detached inside the layered call chain
- add `try Task.checkCancellation()` in CPU-bound loops between awaits
LANGUAGE-LEVEL questions (Sendable conformance, isolation rules, actor reentrancy,
Swift 6 migration) are NOT this skill — they belong to a dedicated Swift
concurrency reference.
- Don't put
@MainActor on a Repository/UseCase to silence a Swift 6 warning —
that pushes I/O onto the main thread. The right fix is usually Sendable on the
types that cross the boundary, not isolation on the class.
@Observable does not imply @MainActor. If the View reads a ViewModel on
main and a background Task writes to it, mark the ViewModel @MainActor
explicitly or you have a data race that may not even warn.
Universal anti-patterns (every architecture)
- Massive View Controller / Massive ViewModel. 600-line types with networking,
persistence, formatting, and navigation all inside. Extract Use Cases, split into
focused types, move navigation to a Coordinator/Router.
- Picking the most ambitious stack "just in case." Clean+VIPER+SPM+Swinject
for a 5-screen utility wastes weeks and obscures intent.
- Mixing patterns per feature — one feature MVC, another MVVM, a third Clean.
Newcomers can't predict where logic lives. (Hybrid UIKit+SwiftUI is not this.)
- Navigation inside the ViewModel/Presenter —
navigationController?.pushViewController(...) in a ViewModel. Signal intent
(closure / Router) instead.
- DI container passed into the components — handing the container to a
ViewModel / Interactor / Router is Service Locator by another name. Inject
explicit dependencies; only Assemblies/Composition Root touch the container.
- Domain importing Data, or DTOs leaking into Domain — the dependency rule
points inward only; map DTO → Entity at the Repository boundary.
- Choosing without writing it down — record the stack in the project guidance
file so every future task reads one source of truth.
- Refusing to migrate when signals appear — stacks fit current size; when MVC
"runs out of steam" (team grows, domain emerges, merge conflicts hurt), migrate
the affected scope rather than forcing the old pattern.
Migration is additive, not a rewrite
The common migrations are mechanical and incremental — never a big-bang rewrite:
- MVC → MVVM: move logic from the VC into a ViewModel one screen at a time; the
VC becomes a thin binding layer.
- MVVM → Clean: extract Use Cases out of the ViewModel; the ViewModel keeps
binding, the Use Cases hold business rules. The ViewModel→Repository call becomes
ViewModel→UseCase→Repository.
- Hand-rolled MVI → TCA:
State→@ObservableState, Intent→Action,
reduce→Reducer.body, closure deps→@Dependency, Store→StoreOf<Feature>.
Migrate only when reducer composition, exhaustive testing, navigation-as-state,
and per-test dependency overrides are all daily needs.
Do each migration feature-by-feature, behind the same public surface, so the app
keeps shipping throughout.