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. "I match the architecture to the team and the product's complexity — I don't cargo-cult one pattern everywhere."

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. "Modularizing into Swift packages 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).

Red flag A view model that constructs URLSession.shared or a global singleton internally can't be tested without the network. "I inject an APIClient protocol so the view model never talks to the network directly — that's what makes it testable with a fake."

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. "I design the mutation queue and conflict policy explicitly up front — offline sync fails at the edges, not the happy path."

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. "Navigation is data in my apps — a router owns the path, so deep links, push notifications, and state restoration all just append a Route."

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.

Red flag Sprinkling DispatchQueue.main.async and locks everywhere is a symptom of undesigned isolation. "I put shared mutable state behind actors and pin UI to @MainActor so the compiler proves I'm race-free, instead of policing it by convention."

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. "I let the compiler enforce module boundaries instead of a lint rule or code review convention."
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. "The local store is the source of truth — the UI never waits on the network to show what the user just did."
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. "I reach for an actor instead of a lock because it makes the race impossible, not just unlikely."
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. "Frame drops are almost always decode work leaking onto the main thread — move it off, cache the result, and the frame rate takes care of itself."
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. "On-device inference isn't just a privacy story — it's zero marginal cost per query and it still works with no signal."
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. "I keep the pyramid shape on purpose — UI tests verify the money paths still work end to end, unit tests do the exhaustive edge-case coverage."