SwiftUI Modular MVVM-C Architecture
Opinionated architecture enforcement for SwiftUI clinic-style apps. This skill aligns to the iOS 26 / Swift 6.2 clinic architecture: modular MVVM-C in local SPM packages, concrete coordinators and route shells in the App target, pure Domain protocols, and Data as the only I/O layer.
Mandated Architecture Stack
┌───────────────────────────────────────────────────────────────┐ │ App target: DependencyContainer, Coordinators, Route Shells │ ├───────────────┬───────────────┬───────────────┬──────────────┤ │ Feature* SPM │ Feature* SPM │ Feature* SPM │ Feature* SPM │ │ View + VM │ View + VM │ View + VM │ View + VM │ ├───────────────────────────────────────────────────────────────┤ │ Data SPM: repository impls, remote/local, retry, sync queue │ ├───────────────────────────────────────────────────────────────┤ │ Domain SPM: models, repository protocols, coordinator protocols│ │ and ErrorRouting/AppError │ ├───────────────────────────────────────────────────────────────┤ │ Shared SPMs: DesignSystem, SharedKit │ └───────────────────────────────────────────────────────────────┘
Dependency Rule: Feature modules import Domain
- DesignSystem only. Features never import Data or other features. App target is the only convergence point.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import Domain
- DesignSystem only (never Data , never sibling features)
-
App target is the convergence point and owns DependencyContainer , concrete coordinators, and Route Shell wiring
-
Domain stays pure Swift and defines models plus repository, *Coordinating , ErrorRouting , and AppError contracts
-
Data owns SwiftData/network/sync/retry/background I/O and implements Domain protocols
-
Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
-
ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
-
Building or refactoring feature modules under local SPM packages
-
Wiring coordinators, route shells, and dependency container factories
-
Defining Domain protocols for repositories, coordinators, and error routing
-
Enforcing Data-only ownership of networking, persistence, and sync
-
Reviewing stale-while-revalidate reads and optimistic queued writes
Non-Negotiable Constraints (iOS 26 / Swift 6.2)
-
@Observable for ViewModels/coordinators, ObservableObject / @Published never
-
No dedicated use-case/interactor layer: ViewModels call Domain repository protocols directly
-
Coordinator protocols live in Domain; concrete coordinators own NavigationPath in App target
-
Route shells live in App target and own .navigationDestination mapping
-
AppError
- ErrorRouting drive presentation policy; ViewModels do not hardcode global error UI
- SwiftData / URLSession / retry / sync queue logic stays in Data package only
Rule Categories by Priority
Priority Category Impact Prefix Rules
1 View Identity & Diffing CRITICAL diff-
6
2 State Architecture CRITICAL state-
7
3 View Composition HIGH view-
6
4 Navigation & Coordination HIGH nav-
5
5 Layer Architecture HIGH layer-
6
6 Dependency Injection MEDIUM-HIGH di-
4
7 List & Collection Performance MEDIUM list-
4
8 Async & Data Flow MEDIUM data-
5
Quick Reference
- View Identity & Diffing (CRITICAL)
-
diff-equatable-views
-
Apply @Equatable macro to every SwiftUI view
-
diff-closure-skip
-
Use @SkipEquatable for closure/handler properties
-
diff-reference-types
-
Never store reference types without Equatable conformance
-
diff-identity-stability
-
Use stable O(1) identifiers in ForEach
-
diff-avoid-anyview
-
Never use AnyView — use @ViewBuilder or generics
-
diff-printchanges-debug
-
Use _printChanges() to diagnose unnecessary re-renders
- State Architecture (CRITICAL)
-
state-observable-class
-
Use @Observable classes for all ViewModels
-
state-ownership
-
@State for owned data, plain property for injected data
-
state-single-source
-
One source of truth per piece of state
-
state-scoped-observation
-
Leverage @Observable property-level tracking
-
state-binding-minimal
-
Pass @Binding only for two-way data flow
-
state-environment-global
-
Use @Environment for app-wide shared dependencies
-
state-no-published
-
Never use @Published or ObservableObject
- View Composition (HIGH)
-
view-body-complexity
-
Maximum 10 nodes in view body
-
view-extract-subviews
-
Extract computed properties/helpers into separate View structs
-
view-no-logic-in-body
-
Zero business logic in body
-
view-minimal-dependencies
-
Pass only needed properties, not entire models
-
view-viewbuilder-composition
-
Use @ViewBuilder for conditional composition
-
view-no-init-sideeffects
-
Never perform work in View init
- Navigation & Coordination (HIGH)
-
nav-coordinator-pattern
-
Every feature has a coordinator owning NavigationStack
-
nav-routes-enum
-
Define all routes as a Hashable enum
-
nav-deeplink-support
-
Coordinators must support URL-based deep linking
-
nav-modal-sheets
-
Present modals via coordinator, not inline
-
nav-no-navigationlink
-
Never use NavigationLink(destination:) — use navigationDestination(for:)
- Layer Architecture (HIGH)
-
layer-dependency-rule
-
Domain layer has zero framework imports
-
layer-usecase-protocol
-
Do not add a use-case layer; keep orchestration in ViewModel + repository protocols
-
layer-repository-protocol
-
Repository protocols in Domain, implementations in Data
-
layer-model-value-types
-
Domain models are structs, never classes
-
layer-no-view-repository
-
Views never access repositories directly; ViewModel calls repository protocols
-
layer-viewmodel-boundary
-
ViewModels expose display-ready state only
- Dependency Injection (MEDIUM-HIGH)
-
di-environment-injection
-
Inject container-managed protocol dependencies via @Environment
-
di-protocol-abstraction
-
All injected dependencies are protocol types
-
di-container-composition
-
Compose DependencyContainer in App target and expose VM factories
-
di-mock-testing
-
Every protocol dependency has a mock for testing
- List & Collection Performance (MEDIUM)
-
list-constant-viewcount
-
ForEach must produce constant view count per element
-
list-filter-in-model
-
Filter/sort in ViewModel, never inside ForEach
-
list-lazy-stacks
-
Use LazyVStack/LazyHStack for unbounded content
-
list-id-keypath
-
Provide explicit id keyPath — never rely on implicit identity
- Async & Data Flow (MEDIUM)
-
data-task-modifier
-
Use .task(id:) as the primary feature data-loading trigger
-
data-async-init
-
Never perform async work in init
-
data-error-loadable
-
Model loading states as enum, not booleans
-
data-combine-avoid
-
Prefer async/await over Combine for new code
-
data-cancellation
-
Use .task automatic cancellation — never manage Tasks manually
How to Use
Read individual reference files for detailed explanations and code examples:
-
Section definitions - Category structure and impact levels
-
Rule template - Template for adding new rules
Reference Files
File Description
references/_sections.md Category definitions and ordering
assets/templates/_template.md Template for new rules
metadata.json Version and reference information