“It worked when it was one screen” is the most common iOS architecture origin story. The app grows, features pile up, and suddenly every change feels risky: views do networking, singletons leak state, tests are painful, and build times creep up. This guide shows a practical recipe for iOS architecture that scales using three ideas that reinforce each other: MVVM (UI stays dumb), Dependency Injection (wiring stays explicit), and feature modules (change stays local).
Quickstart
If you want fast wins, do these in order. They don’t require a full rewrite and they immediately reduce “spooky action at a distance” (where changes in one place break something far away).
1) Pick feature boundaries (even if your project is still one target)
Decide what your app is made of: Profile, Settings, Search, Checkout… each with its own UI + logic. This becomes the map for feature modules later.
- List 4–8 user-visible features (not technical layers)
- Write one sentence per feature: “owns UI for ___ and talks to ___”
- Pick one feature to refactor first (the one you touch weekly)
2) Move business logic out of views into a ViewModel
The view should render state and forward user intent. The ViewModel owns state transitions and calls services.
- Create a
Stateenum (idle/loading/loaded/failed) - Expose read-only state to the view
- Make user actions explicit:
onAppear(),refresh(),tapSave()
3) Introduce constructor injection (no container yet)
Start simple: pass dependencies through initializers. You’ll feel the benefits in tests immediately.
- Replace direct calls to networking/storage with protocols
- Pass concrete implementations from the “composition root”
- Use fakes/stubs in tests
4) Add a tiny DI container (only when wiring hurts)
A container is just a convenience for wiring; it shouldn’t hide your app’s structure.
- Register services in one place (App start)
- Resolve only at the edges (views/coordinators)
- Allow overrides for previews/tests
5) Extract one feature into a module (SPM is the easiest)
Don’t modularize “Core” first. Modularize a real feature so you learn the dependency lines.
- Create a Feature package (UI + ViewModel + feature-local models)
- Expose only what the app shell needs (a screen / entry point)
- Keep feature internals internal by default
6) Add a rule: “feature-to-feature calls go through interfaces”
This prevents “import the other feature” dependency tangles. Cross-feature flows should be coordinated by the app shell (or a navigation layer).
- Expose protocols or small “feature APIs” (not concrete types)
- Keep navigation decisions out of ViewModels
- Prefer events/callbacks over direct imports
You can adopt this architecture screen-by-screen. Start with one feature you change often, and migrate others when you touch them. The goal is not “perfect structure,” it’s less coupling per change.
Overview
Most architecture debates are really about one thing: where does change go? In a scalable iOS codebase, a new feature should touch a small number of files, live in one place, and be testable without booting half the app.
What this post covers
- How MVVM keeps UI code predictable (SwiftUI or UIKit)
- How DI makes dependencies explicit and tests cheap
- How feature modules prevent “one giant target” entropy
- A step-by-step layout you can copy and evolve
- Common traps (and what to do instead)
A simple mental model: 3 layers, 1 direction
| Layer | Owns | Depends on | Tests look like |
|---|---|---|---|
| View (SwiftUI/UIViewController) | Layout + rendering + user events | ViewModel interface | Snapshot/UI tests |
| ViewModel | State + actions + async work orchestration | Protocols (services/use-cases) | Fast unit tests with fakes |
| Services (network/storage/domain) | IO + business rules + data mapping | System frameworks / clients | Unit tests + integration tests |
Feature modules add a fourth dimension: the same layers exist inside each feature, but the feature’s internals stay private. The app shell composes features together at the edges (navigation, deep links, global DI registration).
If you’re shipping a quick prototype, keep it simple. If you’re adding the third or fourth significant screen, or onboarding a second developer, structure starts paying for itself fast.
Core concepts
To make iOS architecture that scales feel concrete, we’ll define the three building blocks (MVVM, DI, modules) and the small “rules of the road” that keep them clean.
MVVM (what it is, and what it isn’t)
In MVVM, the View displays state and forwards user intent, while the ViewModel transforms intent into state changes by calling services. The ViewModel should not know about concrete UI details (no pushing view controllers, no SwiftUI navigation paths, no alert presentation logic beyond “show error state”).
Good ViewModel responsibilities
- Expose state: loading, data, empty, error
- Expose actions: refresh, submit, select item
- Map domain models into UI-ready view data
- Coordinate async work and cancellation
Avoid in ViewModels
- Direct navigation decisions (push/present)
- Using
URLSessiondirectly (prefer a service) - Storing global singletons and mutable shared state
- Becoming a “god object” that owns everything
Dependency Injection (DI) as “wiring,” not magic
DI is a simple idea: instead of a type reaching out to find dependencies (singletons, globals), you provide what it needs. The biggest payoff is testability: you can swap a network client for a fake in one line.
Three DI patterns you’ll see in iOS
| Pattern | What it looks like | When to use |
|---|---|---|
| Constructor injection | init(service: ...) |
Default choice; most explicit and test-friendly |
| Property injection | var service: ... |
When construction is constrained (legacy, storyboards) |
| Container resolve at edges | container.resolve(...) |
To reduce wiring in app shell / coordinators |
A container used everywhere turns into a hidden global. Keep resolution at the edges (composition root), and keep core logic receiving dependencies explicitly.
Feature modules: structure that enforces boundaries
Feature modules are how you keep a growing app from collapsing into one folder. A feature module typically contains: the screens for a feature, its ViewModels, its feature-local models, and small helpers. The module exposes a minimal public API (often a root view/controller factory), and hides internal details.
Why modules help (even for solo devs)
- Forces you to define what’s public vs internal
- Prevents circular dependencies (or makes them obvious)
- Improves build times as projects grow (when modularized well)
- Enables parallel work across features
What modules are not
- A “folder with a fancy name” (it should have boundaries)
- A reason to share everything in a giant Core
- A replacement for good naming and clear responsibilities
- Something you must do on day one for every app
Composition root: the one place where wiring is allowed
The composition root is the top of your app where you assemble the object graph:
create services, register them, create ViewModels, and build the first screen.
For SwiftUI, this is typically your @main App. For UIKit, it can be SceneDelegate
or an app coordinator.
Step-by-step
This is a practical, “copy and evolve” path to implement MVVM + DI + feature modules. If you’re migrating a messy app, do it feature-by-feature. If you’re starting fresh, you can do the whole setup in a day.
Step 1 — Decide your feature list and “public API” per feature
Before you create modules, decide what counts as a feature and what it should expose. A good default is: each feature exposes a single entry point (a SwiftUI view or a UIViewController factory), plus lightweight models/events.
Feature API checklist
- One entry point:
makeRootView(...)ormakeViewController(...) - Inputs are protocols or plain data (avoid passing “everything”)
- Outputs are callbacks/events (avoid directly importing other features)
- Feature internals remain
internalby default
Step 2 — Create a module structure (SPM-friendly)
Swift Package Manager (SPM) is the simplest way to modularize modern iOS projects: packages build fast, integrate well, and make dependencies explicit. Start with one feature module and one shared module (only for truly shared code).
# One practical layout (feature-first)
# (You can keep everything in one repo and add packages over time.)
mkdir -p MyApp/Packages
cd MyApp/Packages
# Create a shared module for cross-feature primitives (keep it small)
swift package init --type library --name AppCore
# Create a feature module (repeat per feature)
swift package init --type library --name FeatureProfile
# Suggested tree (conceptual):
# MyApp/
# App/ # iOS app target (composition root, navigation shell)
# Packages/
# AppCore/ # shared types, networking base, design primitives (only what truly repeats)
# FeatureProfile/ # screens + VM + feature-specific models
# FeatureSettings/ # another feature, independent by default
Don’t move code to AppCore just because two features need it once. Duplicate small helpers until the abstraction is obvious. Shared modules should grow slowly, or they become the new global dumping ground.
Step 3 — Define service protocols close to where they’re used
Protocols are your seam for DI. A useful default: define a protocol in the feature module that needs it (or in AppCore only if it’s genuinely shared). Concrete implementations can live in AppCore or an Infrastructure package.
Protocol placement guideline
- Feature-local: “Only Profile needs this” → define in FeatureProfile
- Shared: “Many features need this” → define in AppCore
- Concrete implementations: can live in AppCore/Infrastructure; keep them swappable
Step 4 — Implement MVVM with explicit state (copyable pattern)
This pattern scales because it makes states explicit (loading vs loaded vs error) and makes dependencies injectable.
It also works in both SwiftUI and UIKit: SwiftUI binds to @Published, UIKit can observe state changes.
import Foundation
import Combine
// 1) Define a protocol (DI seam)
protocol UserProfileServicing {
func loadUser(id: UUID) async throws -> User
}
// 2) Keep your domain model simple
struct User: Equatable {
let id: UUID
let name: String
}
// 3) ViewModel owns state transitions (not UI navigation)
@MainActor
final class ProfileViewModel: ObservableObject {
enum State: Equatable {
case idle
case loading
case loaded(User)
case failed(message: String)
}
@Published private(set) var state: State = .idle
private let service: UserProfileServicing
private let userID: UUID
private var loadTask: Task<Void, Never>?
init(userID: UUID, service: UserProfileServicing) {
self.userID = userID
self.service = service
}
func onAppear() {
// Avoid duplicate loads if the view re-appears frequently
guard case .idle = state else { return }
refresh()
}
func refresh() {
loadTask?.cancel()
state = .loading
loadTask = Task {
do {
let user = try await service.loadUser(id: userID)
state = .loaded(user)
} catch {
// Map error to something user-friendly (keep it deterministic for tests)
state = .failed(message: "Couldn’t load profile. Please try again.")
}
}
}
func onDisappear() {
loadTask?.cancel()
loadTask = nil
}
}
// Tip for tests:
// Provide a fake service that returns a fixed User (or throws), then assert the state transitions.
Make it scale: three tiny rules
- Keep state transitions centralized (don’t set state from multiple helper objects)
- Make errors deterministic (“message strings” or error categories)
- Cancel in-flight work when it no longer matters (scroll, navigation, dismiss)
Step 5 — Add DI as a convenience (keep it at the edges)
Once you have more than a few services, wiring by hand gets noisy. A small container reduces the boilerplate, but the shape of your dependencies should remain obvious. The pattern below supports overrides (tests, previews, staging) without turning DI into a global mystery.
import Foundation
import SwiftUI
// A tiny DI container (enough for most apps)
final class Container {
static let shared = Container()
private var factories: [ObjectIdentifier: () -> Any] = [:]
private var parent: Container?
init(parent: Container? = nil) {
self.parent = parent
}
func register<T>(_ type: T.Type, factory: @escaping () -> T) {
factories[ObjectIdentifier(type)] = factory
}
func resolve<T>(_ type: T.Type = T.self) -> T {
if let factory = factories[ObjectIdentifier(type)], let value = factory() as? T {
return value
}
if let parent {
return parent.resolve(type)
}
fatalError("No registration for \\(type)")
}
func makeChild() -> Container {
Container(parent: self)
}
}
// SwiftUI Environment hookup (so views can access the container if needed)
private struct ContainerKey: EnvironmentKey {
static let defaultValue: Container = .shared
}
extension EnvironmentValues {
var container: Container {
get { self[ContainerKey.self] }
set { self[ContainerKey.self] = newValue }
}
}
// Composition root example (App start)
@main
struct MyApp: App {
init() {
let c = Container.shared
c.register(UserProfileServicing.self) { LiveUserProfileService() }
}
var body: some Scene {
WindowGroup {
// Resolve at the edge, then inject explicitly into ViewModels
let c = Container.shared
ProfileScreen(viewModel: ProfileViewModel(
userID: UUID(),
service: c.resolve(UserProfileServicing.self)
))
.environment(\.container, c)
}
}
}
// Tests/previews:
// let testContainer = Container.shared.makeChild()
// testContainer.register(UserProfileServicing.self) { FakeUserProfileService() }
// Build the screen using testContainer and you have controlled dependencies.
If ViewModels can pull dependencies from a global container, your tests become implicit and fragile. Prefer constructor injection: ViewModels should be buildable from plain values + protocol dependencies.
Step 6 — Keep navigation in the app shell (not in ViewModels)
Navigation is where coupling sneaks back in. A scalable approach is to keep navigation decisions in a coordinator/router (UIKit) or in the app shell / flow container (SwiftUI). ViewModels emit events; the shell interprets them and navigates.
A clean event style
- ViewModel exposes an
Eventstream or callback - Feature returns “user did X” rather than “push Screen Y”
- Shell decides the route (and can change it without touching the feature)
Why this matters
Cross-feature flows (onboarding → paywall → home) should be composed by the shell. When a feature owns navigation, it tends to import other features and create cycles.
Step 7 — Add tests where they’re cheapest: ViewModels and services
The goal is not “test everything.” The goal is to test the code that breaks most often (logic and IO boundaries) without UI runtime cost. With DI seams, unit tests become tiny and fast.
High-leverage test plan
- ViewModel: state transitions for success, error, retry, cancellation
- Service: parsing/mapping and error handling
- One or two UI smoke tests per critical flow (login, checkout)
Step 8 — Scale modules carefully: fewer dependencies, clearer “shared”
Modularization can backfire if you create too many tiny modules or shove everything into shared code. A healthy direction is: features depend on AppCore, but not on each other; the app target depends on all features.
A dependency rule that stays sane
| Allowed | Not allowed | Fix |
|---|---|---|
| App → FeatureA, FeatureB | FeatureA → FeatureB | Move shared protocol to AppCore or use events |
| Feature → AppCore | AppCore → Feature | Keep AppCore independent (no UI feature types) |
| Feature → Apple frameworks | Feature → “Everything” utilities | Prefer feature-local helpers; share only proven abstractions |
Common mistakes
These pitfalls show up in real iOS apps (not just tutorials). Each one creates coupling, slows iteration, or makes bugs harder to isolate. The fix is usually smaller than you think.
Mistake 1 — “MVVM” where the ViewModel is just a view controller
If the ViewModel knows about navigation and UI presentation, you didn’t decouple—you renamed.
- Fix: ViewModel emits state + events; shell performs navigation.
- Fix: keep UI types out of ViewModels (no
UIViewController, noNavigationPath).
Mistake 2 — Networking or persistence inside views
Views become untestable and hard to reason about when they do IO.
- Fix: create a service protocol; call it from the ViewModel.
- Fix: store results as state and render from state only.
Mistake 3 — Singletons everywhere (“it’s convenient”)
Globals hide dependencies and create shared mutable state bugs.
- Fix: pass dependencies through initializers (constructor DI).
- Fix: if you must have a shared instance, keep it behind a protocol and inject it.
Mistake 4 — A DI container used as a global service locator
Resolution inside core logic makes tests implicit and debugging painful.
- Fix: resolve only at the edges (composition root, coordinators, view factories).
- Fix: keep ViewModels/services “container-free.”
Mistake 5 — “Core” becomes a dumping ground
Shared modules grow faster than features and become impossible to refactor.
- Fix: duplicate small utilities until the shared abstraction is obvious.
- Fix: move code into shared modules only when multiple features truly need it.
Mistake 6 — Feature modules importing each other
This creates dependency cycles and “everything depends on everything.”
- Fix: coordinate flows in the app shell; communicate via events/callbacks.
- Fix: define shared interfaces in AppCore, not in other features.
Mistake 7 — No explicit UI states (loading/empty/error)
Without states, the UI becomes a pile of booleans and edge cases.
- Fix: use a single
Stateenum and render from it. - Fix: define empty and error states early (shipping UX depends on them).
Mistake 8 — Async work without cancellation
Old requests race with new ones and update the UI at the wrong time.
- Fix: store and cancel tasks when reloading or disappearing.
- Fix: keep UI updates on the main actor.
Reduce ceremony, not boundaries. Keep the rules small: (1) views render state, (2) ViewModels own state transitions, (3) dependencies are injected, (4) features don’t import other features.
FAQ
Do I need Clean Architecture on top of MVVM?
Not necessarily. If MVVM + DI keeps your ViewModels small and your services well-defined, that’s often enough. Add extra layers (use-cases, interactors) only when you feel real pain: duplicated orchestration logic, complicated business rules, or multiple UIs sharing the same workflows.
Should I use a Coordinator pattern for navigation?
Use a “navigation shell” concept, regardless of framework. In UIKit, coordinators are a proven way to centralize routing. In SwiftUI, you can achieve the same separation by keeping navigation state and routing decisions outside ViewModels (e.g., in an app flow container).
Where should models live: in AppCore or inside features?
Default to feature-local models. Only move models to AppCore when they represent a truly shared concept across multiple features (and you’re confident the definition is stable). Otherwise, keep types close to where they’re used to avoid “global type coupling.”
How do feature modules work with SwiftUI?
Very well—if you expose a small entry point. A feature can export a ProfileScreen (SwiftUI view) or a factory function.
The app shell composes features and provides dependencies (via constructor DI or a container resolved at the edge).
Is a DI container required?
No. Constructor injection alone scales surprisingly far. Introduce a container only when wiring becomes repetitive, and keep it limited to the composition root / edges so dependencies remain explicit in core types.
How do I test ViewModels that use async/await?
Inject a fake service and assert state transitions. Keep your ViewModel’s state deterministic and map errors into stable categories. Tests then become: create VM with a fake → call action → await task completion → assert state.
What’s the most important rule for iOS architecture that scales?
Make change local. If a feature change requires touching five unrelated folders, the architecture is leaking. Feature modules + DI seams + MVVM state boundaries work together to keep changes contained.
Cheatsheet
A scan-fast checklist you can keep open while refactoring or starting a new feature.
MVVM rules (keep it boring)
- Views render state; they don’t do IO
- ViewModels own state transitions and call services
- State is explicit (enum beats many booleans)
- User intent is explicit (methods for actions)
- Cancel async work when it no longer matters
DI rules (keep it explicit)
- Prefer constructor injection
- Protocols are seams; implementations are swappable
- Resolve dependencies at the edges (composition root)
- Allow overrides for tests and previews
- Avoid resolving from inside ViewModels/services
Feature module rules (keep change local)
- Start by modularizing one real feature
- Expose a small public API (entry point + events)
- Keep feature internals
internalby default - Features don’t import other features
- Shared modules grow slowly (avoid Core bloat)
A quick “Do / Don’t” map
| Do | Don’t |
|---|---|
| Render UI from a single state enum | Scatter flags like isLoading + hasError + isEmpty |
| Inject services via init | Grab singletons directly in ViewModels |
| Keep navigation in the shell/coordinator | Push/present screens from ViewModels |
| Modularize features and hide internals | Let features import each other “just this once” |
Pick one screen. Extract a ViewModel with explicit state. Inject one service protocol. Then decide if wiring needs a container. That sequence solves most “architecture” problems without ceremony.
Wrap-up
Scaling an iOS app is less about picking the perfect pattern and more about enforcing a few boundaries that keep change local. MVVM keeps UI predictable, DI keeps dependencies explicit and testable, and feature modules keep your project from becoming one giant ball of code.
A practical next-steps plan (30–60 minutes)
- Choose one feature you frequently modify
- Introduce a ViewModel with a single
Stateenum - Replace direct IO with a service protocol + injected implementation
- Add one unit test that checks a key state transition
- (Optional) Extract that feature into an SPM module if it keeps growing
Want to go deeper? The related posts below cover layout rules (SwiftUI), performance profiling, mobile security basics, and deep linking edge cases. Together, they form the “shipping toolbox” around a scalable architecture.
Quiz
Quick self-check (demo). This quiz is auto-generated for mobile / development / ios.