The senior iOS
architecture cheatsheet
MVVM + Coordinator + Clean Architecture. Production-proven at enterprise scale. One page. No fluff.
01 · Layers The stack, top to bottom
Dependencies flow downward only. Lower layers never know about upper ones. This is how you make code testable, swappable, and scalable.
02 · Patterns Nine core patterns, in order of impact
Model–View–ViewModel
UI binds to @Published state. ViewModel owns state and
translates events into actions. Never
import UIKit inside a ViewModel.
Navigation as its own object
Pulls navigation out of View and ViewModel. One coordinator per
flow. Handles present, push, deep links,
dismiss. Views and ViewModels stay pure.
Use Cases are king
One use case per business action: LoginUser,
FetchHomeFeed. They compose repositories. ViewModels
call use cases — never repositories directly.
Constructor injection, always
Pass dependencies through init. Avoid singletons and
service locators. If DI feels heavy, that's a code smell — the
object probably does too much.
Abstract the collaborator, not the concept
Protocols exist to enable swap (fake/mock/alt). Don't protocolize everything — only what crosses a layer boundary or needs a test double.
Single source of truth
One @Published state enum per screen:
idle / loading / loaded / error. No duplicate booleans.
Derive everything from state.
Swift Concurrency over Combine
Use async/await +
AsyncSequence for new code. Mark UI types
@MainActor. Reach for Combine only when existing code
already uses it.
Repository hides the storage
ViewModels never know if data came from network, cache, Core Data, or SwiftData. Repository decides. Swap storage without touching the UI layer.
ViewModels and Use Cases first
Test state transitions and business rules. Skip pure
View tests — snapshots cover them. Mock services at the
protocol boundary with stubs, not frameworks.
03 · Snippet A proper ViewModel in <30 lines
@MainActor final class HomeViewModel: ObservableObject { enum State { case idle, loading, loaded([Item]), error(String) } @Published private(set) var state: State = .idle private let fetchFeed: FetchHomeFeedUseCase private let analytics: AnalyticsService init(fetchFeed: FetchHomeFeedUseCase, analytics: AnalyticsService) { self.fetchFeed = fetchFeed self.analytics = analytics } func onAppear() async { state = .loading analytics.track(.homeOpened) do { let items = try await fetchFeed.run() state = .loaded(items) } catch { state = .error(error.localizedDescription) } } }
04 · Red flags When you've gone wrong
2,000-line ViewController
You skipped MVVM. Extract state to a ViewModel. Extract navigation to a Coordinator. Extract networking to a Service.
.shared everywhere
Singletons make tests impossible. Inject through init.
If bootstrapping gets noisy, add a composition root
(AppDependencies).
ViewModel imports UIKit
Leaking UI into state. Pass events in, publish state out. Never
UINavigationController in a ViewModel.
Parallel booleans
isLoading, hasError, isEmpty,
didLoad — four booleans encoding one state. Collapse
into a single enum.
Repositories returning JSON
Repos must return domain types, not transport types. Never pass
[String: Any] up to a ViewModel. Decode at the
boundary.
No test coverage on ViewModels
If you can't test state transitions without launching the app, your architecture is wrong — not your tests.