System design
iOS Architecture Guide
A tour of how iOS apps are designed at scale — patterns, module boundaries, data flow, concurrency, and release. This is the “how would you build a large iOS app” material that separates senior and architect answers from feature work.
01 · Architecture patterns: MVC → MVVM → TCA
iOS has moved from UIKit's MVC (which often became "Massive View Controller") to MVVM, where a view model holds presentation logic and state and the view stays thin and testable. SwiftUI's data flow is naturally MVVM-friendly; some teams go further with a unidirectional / TCA (The Composable Architecture) approach: state in one place, changes via actions through a reducer, side effects isolated and testable.
02 · Modularization with Swift packages
As an app grows, a single target becomes a bottleneck: slow builds, tangled dependencies, and merge pain. Split it into local Swift packages — feature modules plus shared Core / DesignSystem / Networking packages — with an explicit dependency direction (features depend on core, never on each other).
03 · Dependency injection & testability
Depend on protocols, not concrete types, and pass dependencies in (initializer injection
or SwiftUI's @Environment) rather than reaching for singletons. That single discipline makes
view models testable with fakes, decouples features from infrastructure, and lets you swap
implementations (e.g. a stub API in previews).
URLSession.shared or a global singleton internally can't be tested without the network.
Inject an APIClient protocol instead.04 · Networking & the repository pattern
Put a repository between your UI and the network/persistence layers. View models ask the
repository for domain models; the repository owns the APIClient, decoding, caching, and the
decision of when to serve cached vs fresh data. This keeps a single source of truth and a single place to
add retries, auth refresh, and pagination.
05 · Offline-first & sync
For apps that must work without a connection, make the local store the source of truth: the UI always reads from SwiftData/Core Data, and a sync engine reconciles with the server in the background. You need a strategy for conflict resolution (last-write-wins, server-authoritative, or per-field merge), change tracking, and retry of failed mutations.
06 · Navigation architecture
Centralize routing so navigation is data, not scattered NavigationLinks. A
router/coordinator owns a NavigationStack path of Route values; features emit
routes, the router decides how to present them. Deep links and push notifications decode into the same
Route values, and state restoration becomes "persist and reload the path".
07 · Concurrency architecture
Design isolation deliberately: UI and view models run on the @MainActor; shared
mutable state (caches, in-memory stores) lives behind actors; expensive work runs in background
tasks and hops back to the main actor only to publish results. Under Swift 6, making types
Sendable and respecting isolation is enforced at compile time — so the architecture has to
be intentional, not accidental.
DispatchQueue.main.async
and locks everywhere. Replace with explicit actor boundaries and @MainActor — the compiler
then proves you're race-free.08 · Observability, flags & release
Production readiness is part of architecture. Wire in structured logging (OSLog), metrics/MetricKit, and crash reporting; gate risky features behind feature flags so you can ship dark and roll out gradually; and use phased release on the App Store with a rollback plan (a flag to disable, or an expedited fix). Define budgets — cold launch, crash-free rate — and watch them.
Deep dives
The senior playbook in a concept → example → problem → solution shape, so each idea sticks as a real engineering decision rather than a definition.
The modular monolith with SPM
Offline-first sync
Actor-based shared state
await its methods. Swift 6 then proves the absence of races at compile time.An image-heavy feed that stays at 120fps
LazyVStack, and give rows stable identity so SwiftUI
reuses them.