iOS

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.

How to choose Small/medium app or a team new to it: MVVM with the Observation framework. Large app, many engineers, heavy logic, a premium on testability and consistency: a unidirectional architecture (TCA or a hand-rolled equivalent) pays off. The senior move is matching the pattern to the team and product, not cargo-culting one.

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).

Payoff Faster incremental and parallel builds, enforced boundaries (no accidental cross-feature imports), feature-level tests and previews, and the option to build a feature in isolation. This is the single highest-leverage architecture decision in a large iOS codebase.

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).

Smell A view model that constructs 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.

Talk track "UI → ViewModel → Repository → (APIClient + Store)." Each layer has one job and a protocol seam for testing. Caching and offline behavior live in the repository, not scattered through views.

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.

Hard parts Ordering and idempotency of queued mutations, clock skew, and partial failures. Design the queue and conflict policy explicitly — this is a favorite senior/architect system-design prompt.

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".

Why Decoupling "what to show" from "how to present it" keeps features independent, makes deep linking and A/B-tested flows trivial, and gives you one place to reason about the entire navigation graph.

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.

Anti-pattern Sprinkling 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.

Architect lens "How do you know it's healthy, and how do you turn it off if it isn't?" A confident answer to that question — flags, dashboards, phased rollout, rollback — is what distinguishes architect-level thinking.

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.

State

MVVM vs The Composable Architecture

Concept MVVM keeps state in view models; TCA centralizes state and routes every change through actions and a reducer, isolating side effects.
Example A multi-step checkout with shared state, analytics, and complex validation across screens.
Problem With ad-hoc MVVM, state leaks across view models, ordering bugs creep in, and side effects are hard to test.
Solution A unidirectional store makes state transitions explicit and exhaustively testable; reach for it when complexity and team size justify the ceremony — otherwise MVVM is lighter.
Modularity

The modular monolith with SPM

Concept Keep one app, but split it into local Swift packages with a strict dependency direction.
Example 30 engineers, one app target, 12-minute incremental builds and constant merge conflicts.
Problem Everything depends on everything; a change anywhere recompiles the world and risks breaking unrelated features.
Solution Feature packages depend only on Core / DesignSystem; builds parallelize, boundaries are enforced by the compiler, and features ship and test independently.
Data

Offline-first sync

Concept The local database is the source of truth; a sync engine reconciles with the server in the background.
Example A notes app users edit on the subway with no signal.
Problem Naive "save to server on tap" loses edits offline and shows spinners everywhere.
Solution Write locally and render instantly; queue mutations with idempotency keys; sync with a defined conflict policy and retry on reconnect.
Concurrency

Actor-based shared state

Concept An actor serializes access to mutable state, eliminating data races by construction.
Example An in-memory image cache hit from many concurrent view tasks.
Problem A plain dictionary cache accessed from multiple tasks crashes or corrupts under load.
Solution Wrap the cache in an actor; callers await its methods. Swift 6 then proves the absence of races at compile time.
Performance

An image-heavy feed that stays at 120fps

Concept Smooth scrolling = cheap cells, lazy loading, and no main-thread work per frame.
Example A social feed of full-resolution photos in a scrolling list.
Problem Decoding 4000px images for 300px cells spikes memory and drops frames; the Time Profiler shows decode on the main thread.
Solution Downsample off the main actor, cache decoded thumbnails in an actor, use LazyVStack, and give rows stable identity so SwiftUI reuses them.
AI

An on-device AI feature

Concept Run inference on device for privacy, offline use, and zero per-call cost; fall back to the server only when needed.
Example Smart search that ranks notes by meaning, not just keywords.
Problem Sending every note to a server is slow, costly, and a privacy liability.
Solution Embed content with a small Core ML model on the Neural Engine, store vectors locally, and rank by cosine similarity — exactly what this guide's own search does in your browser.
Layers

Clean Architecture & the dependency rule

Concept Concentric layers (domain → use cases → adapters → frameworks) with dependencies pointing only inward; the domain imports nothing.
Example A pricing engine reused across an app, a widget, and a server-side Swift target.
Problem Business rules tangled with SwiftUI and URLSession can't be reused or tested without spinning up the whole app.
Solution Put rules in a framework-free domain package; outer layers implement its protocols and wire up at a composition root. Now the core is portable and unit-tested with no simulator.
Testing

A test strategy that actually scales

Concept A pyramid: many fast unit tests, fewer integration tests, a thin layer of end-to-end UI tests on critical flows.
Example Login, checkout, and sync in a growing app with a 20-minute CI budget.
Problem Leaning on slow, flaky XCUITests for everything makes CI slow and reds out on timing, so people stop trusting it.
Solution Push logic into injectable view models covered by fast unit tests; keep a handful of UI tests for the money paths; parallelize on simulator clones via a test plan.