
Thesis: In 2026 the default architecture for a production iOS app that you actually plan to maintain is MVVM + Coordinators + Dependency Injection, written in Swift 6 with strict concurrency, using SwiftUI where it fits and UIKit where it doesn't — not because MVVM is fashionable, but because it is the only pattern Apple's own frameworks (Combine, SwiftUI, @Observable, Swift Testing) are designed around. VIPER has become heavy, MVC at scale is unmaintainable, and TCA is powerful but opinionated. MVVM is the boring, productive middle that lets a rotating team ship features without re-learning the app every sprint.
The 2026 iOS MVVM stack: Swift 6 strict concurrency, @Observable, Swift Data, TCA for complex apps, async/await everywhere. Expect 30–50% less boilerplate than MVVM + Combine from 2022 — at the cost of a steeper learning curve on actor isolation and ownership rules.
Fora Soft has shipped 60+ Swift apps on this architecture across video, EdTech, and SaaS — including real-time clients that push hundreds of state updates per second. This playbook is the exact stack, patterns, pitfalls, and KPIs we use in 2026.
Key takeaways
- MVVM + Coordinators + DI is the 2026 production default: predictable flow, testable ViewModels, decoupled navigation, fast onboarding.
- SwiftUI uses MVVM natively via
@Observable(iOS 17+) and@State/@Binding— no external reactive library required for new screens. - UIKit screens still benefit from MVVM when paired with Combine (Apple-native) or RxSwift (legacy) for binding ViewInputData to the View.
- Coordinators isolate navigation so ViewControllers are reusable, deep links are first-class, and flows are composable across tabs and modals.
- Dependency Injection (DITranquillity, Factory, or Swinject) lets you swap a live network layer for a mock in one line — essential for Swift Testing.
- VIPER and Clean Swift remain valid for ≥ 15-screen teams where role boundaries dominate, but cost 2× the boilerplate for most products.
- TCA (The Composable Architecture) is excellent for complex state machines, worth the learning curve only when state determinism is a product requirement.
Why Fora Soft wrote this playbook
We have been building custom iOS software since 2005. Across 60+ shipped Swift apps — including BrainCert (500M+ learning minutes), AstroPay, and a portfolio of WebRTC video clients — we have paid the migration tax for every major iOS architecture trend: MVC in the Objective-C years, MVP, MVVM with ReactiveCocoa, VIPER, MVVM-C with RxSwift, and now MVVM with Swift Concurrency, Combine, and SwiftUI's @Observable. The pattern that survived every generation — and got lighter with each one — is MVVM + Coordinators + DI. This article is the exact skeleton, code, and guardrails we hand a new iOS engineer on day one, updated for Swift 6, iOS 17+, and 2026 tooling.
Reach for MVVM-C when: your iOS app has > 50 screens, multiple deep-link entry points, and 3+ engineers. Below that, plain MVVM is enough.
Need a senior iOS team that ships on day one?
We drop into existing Swift codebases, align them to modern MVVM-C, and ship production features inside the first sprint.
Book a 30-min architecture review →The iOS architecture landscape in 2026 — what actually ships
The iOS architecture debate has cooled. Apple's own frameworks have converged on a unidirectional data flow that looks like MVVM, and the community has stopped treating architecture as a taste test. In 2026, four patterns dominate new-project decisions.
MVC (Apple's original) remains perfectly acceptable for single-developer utility apps under ten screens. Beyond that, ViewControllers become Massive View Controllers and the team pays the price in every sprint.
MVVM (+ Coordinators + DI) is the current production default — the subject of this article. SwiftUI's @Observable macro (iOS 17+) codified MVVM so thoroughly that Apple engineers now describe SwiftUI's default flow as "effectively MVVM." It is the lowest-friction way to ship a testable, scalable app in 2026.
VIPER (View, Interactor, Presenter, Entity, Router) enforces strict role boundaries and shines in very large teams (15+ iOS engineers, 30+ screens) where the boilerplate buys predictable ownership. For most teams it is 2× the code for 1.1× the safety.
TCA (The Composable Architecture) from Point-Free is the strongest option when determinism matters — financial apps, multiplayer game clients, complex wizards with undo. It has a steep learning curve, a strong opinion about testing, and a vibrant ecosystem. Pick it when reducers-as-state-machines fit your product.
The MVVM-C anatomy — seven moving parts, one repeating rhythm
Our production skeleton has seven named roles. Every screen uses the same seven, which is what makes onboarding under two days possible.
Skip Coordinator when: you ship a single-flow app (camera, calculator, single-purpose tool). The overhead is real and the win is small.
Model — plain Swift types (struct, actor) that represent domain data. No framework awareness. Immutable where possible.
Provider — the data-layer façade for a screen. Talks to services (network, DB, cache) and exposes a single State observable. Replaces the "Massive Model" smell.
ViewModel — the bridge. Subscribes to the Provider's state, maps it to ViewInputData, receives Events from the View, and owns presentation logic.
View / ViewController — renders ViewInputData, forwards user actions as Events. Contains zero business logic.
Coordinator — owns navigation. Creates ViewModels, attaches handlers (onNoteSelected, onCreateNote), and decides which screen comes next.
Router — a thin UIKit wrapper around push/pop/present, injected into the Coordinator so coordinators are testable.
DI container — declarative registration of Providers, ViewModels, and Views. In 2026 we use DITranquillity for UIKit-heavy apps and Factory for SwiftUI-first apps.
@Observable without coordinators is perfectly fine.
MVVM in SwiftUI with @Observable (iOS 17+)
SwiftUI's 2023 Observation framework removed the last excuse to avoid MVVM on modern iOS. A ViewModel is a class annotated with @Observable; the View reads it with @State or @Bindable; changes propagate automatically. No ObservableObject, no @Published, no Combine subscription plumbing.
import Observation
@Observable
final class NotesListViewModel {
var notes: [Note] = []
var isLoading = false
private let provider: NotesProviding
init(provider: NotesProviding) {
self.provider = provider
}
func load() async {
isLoading = true
defer { isLoading = false }
notes = (try? await provider.fetchAll()) ?? []
}
}
struct NotesListView: View {
@State private var viewModel: NotesListViewModel
init(viewModel: NotesListViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.notes) { NoteRow(note: $0) }
.overlay { if viewModel.isLoading { ProgressView() } }
.task { await viewModel.load() }
}
}
Three things to notice. First, the ViewModel has zero SwiftUI imports — it is testable under Swift Testing without a renderer. Second, the View holds the ViewModel in @State, not @StateObject; @Observable makes @StateObject obsolete. Third, the Provider is injected — swap it for a mock in tests with one line.
UICollectionView layouts, custom camera UI).
MVVM in UIKit with Combine — the Fora Soft skeleton
For UIKit apps and mixed codebases we still use the Provider → ViewModel → View trio, wired with Combine (Apple-native) or RxSwift (legacy projects). Below is the minimum compilable skeleton we drop into day-one projects.
Test the right things: ViewModels with unit tests at 70%+ coverage; Views with snapshot tests; Coordinators with integration tests.
// MARK: - Router protocol (enables coordinator testing)
import UIKit
protocol Presentable { func toPresent() -> UIViewController? }
extension UIViewController: Presentable { func toPresent() -> UIViewController? { self } }
protocol Router: Presentable {
func push(_ module: Presentable?, animated: Bool)
func popModule(animated: Bool)
func present(_ module: Presentable?, animated: Bool)
func dismissModule(animated: Bool, completion: (() -> Void)?)
func setRootModule(_ module: Presentable?)
}
Provider — exposes @Published state and a small mutation API.
import Combine
struct NotesListState { var notes: [Note] = [] }
protocol NotesListProviding {
var state: AnyPublisher<NotesListState, Never> { get }
var currentState: NotesListState { get }
func reload() async
}
final class NotesListProvider: NotesListProviding {
@Published private(set) var currentState = NotesListState()
var state: AnyPublisher<NotesListState, Never> { $currentState.eraseToAnyPublisher() }
private let store: NoteStoring
init(store: NoteStoring) { self.store = store }
func reload() async {
let notes = (try? await store.all()) ?? []
await MainActor.run { currentState.notes = notes }
}
}
ViewModel — subscribes to Provider, exposes ViewInputData and Events.
struct NotesListViewInputData { let rows: [NoteRowModel] }
enum NotesListEvent { case viewDidAppear, select(Note.ID), createTapped }
protocol NotesListViewModel: AnyObject {
var viewInputData: AnyPublisher<NotesListViewInputData, Never> { get }
func send(_ event: NotesListEvent)
var onNoteSelected: ((Note) -> Void)? { get set }
var onCreateNote: (() -> Void)? { get set }
}
Three small rules make this skeleton survive in production. One, ViewModels never import UIKit — that guarantees they stay testable. Two, Providers own all side effects — networking, persistence, keychain. Three, Views never talk to Providers — the Coordinator's job is to wire them.
UIKit components to preserve, or require UICollectionViewCompositionalLayout performance that SwiftUI's LazyVStack cannot match.
Coordinators — why navigation belongs in its own object
Coordinators solve a specific smell: ViewControllers that know about each other. Without coordinators, NotesListViewController imports NoteDetailViewController to push it — which is fine until the detail screen is reused from the search flow, the deep link flow, and the share-extension flow, each of which has slightly different arguments. Now NoteDetailViewController imports five siblings and is untestable.
Coordinators invert that dependency. The ViewController only knows "the user tapped a note"; the Coordinator decides what happens next. Three practical rules from our codebase:
One Coordinator per feature flow, not per screen. AuthCoordinator, NotesCoordinator, SettingsCoordinator. Each owns the Router for its flow.
AppCoordinator is the root. It reads auth state and decides between AuthCoordinator and MainCoordinator. It also receives deep links and routes them to the right child coordinator.
Coordinators hold strong references to children. When a flow ends, the coordinator releases its child; ARC cleans up the ViewControllers. This is how MVVM-C avoids the memory leaks that plagued RxSwift-based MVVM in the 2018 era.
final class NotesCoordinator: BaseCoordinator {
private let router: Router
private let container: DIContainer
init(router: Router, container: DIContainer) {
self.router = router; self.container = container
}
override func start() {
let deps: NotesListDependency = container.resolve()
deps.viewModel.onNoteSelected = { [weak self] in self?.pushDetail(note: $0) }
deps.viewModel.onCreateNote = { [weak self] in self?.pushEditor(mode: .create) }
router.setRootModule(deps.viewController)
}
private func pushDetail(note: Note) {
let deps: NoteDetailDependency = container.resolve((note: note))
router.push(deps.viewController, animated: true)
}
private func pushEditor(mode: EditorMode) { /* ... */ }
}
Dependency Injection — the contract that makes testing possible
Dependency Injection is the single biggest leverage point in an iOS codebase. Without it, you cannot unit-test a ViewModel without spinning up a real network. With it, you swap the Provider for a mock in one line and test every branch in microseconds.
Common failure mode: fat ViewModels that absorb business logic. Push pure logic into UseCases and keep VMs as orchestrators.
In 2026 we use one of three DI libraries depending on the app.
DITranquillity — the workhorse for UIKit-heavy apps. Declarative registration, scopes, circular-dependency detection, zero runtime overhead. Our default.
Factory (by Michael Long) — SwiftUI-first, zero-dependency, macro-based in its 2.0 release. Beautiful ergonomics for SwiftUI ViewModels.
Swinject — the oldest option, still widely supported, slightly heavier at runtime. Pick it if you inherit a codebase already using it.
// DITranquillity registration
final class NotesListPart: DIPart {
static func load(container: DIContainer) {
container.register { NotesListProvider(store: $0.resolve()) }
.as(NotesListProviding.self)
.lifetime(.objectGraph)
container.register { NotesListViewModelImpl(provider: $0.resolve()) }
.as(NotesListViewModel.self)
.lifetime(.objectGraph)
container.register(NotesListViewController.init(viewModel:))
.lifetime(.objectGraph)
container.register(NotesListDependency.init(viewModel:viewController:))
.lifetime(.prototype)
}
}
Three lifetime rules save 90% of DI bugs. Prototype for factories that return a fresh instance every resolve. Object graph for dependencies shared within one screen build-up. Perma (singleton) for app-wide services — network client, analytics, feature flags.
Swift Concurrency & the actor model — 2026 defaults
Swift 6's strict-concurrency mode is the single biggest architecture shift of the past three years. async/await replaces completion-handler soup; actor isolates shared mutable state; @MainActor gives compile-time guarantees that UI code runs on the main thread.
How this interacts with MVVM-C:
Providers become actors for anything with shared mutable state (caches, pagination cursors, in-flight request tracking). actor NotesListProvider makes data races a compile-time error.
ViewModels are @MainActor. All UI-bound state updates happen on the main actor by default; await hops off for networking and back on for state mutation. No more DispatchQueue.main.async sprinkled through view models.
Task cancellation is first-class. Structured concurrency lets you cancel a running fetch when the user leaves a screen; with .task { } in SwiftUI this is automatic.
@MainActor @Observable
final class NotesListViewModel {
private(set) var notes: [Note] = []
private let provider: NotesProviding
init(provider: NotesProviding) { self.provider = provider }
func load() async {
do {
notes = try await provider.fetchAll()
} catch is CancellationError {
// expected when view disappears
} catch {
// surface to user
}
}
}
final class is fine.
Testing MVVM-C — Swift Testing, snapshots, and the 80/20 pyramid
The whole point of MVVM-C is testability. In 2026 we test three layers with three tools.
ViewModels under Swift Testing — the new Apple framework (Xcode 16+) replaces XCTest with @Test functions and #expect(). ViewModels are pure logic over injected Providers; we aim for > 80% branch coverage here.
Providers under XCTest with mocked URLSession — contract tests that verify request shape, decoding, and error mapping against real fixtures.
Views via snapshot tests (pointfreeco/swift-snapshot-testing) and SwiftUI previews with #Preview. We never unit-test Views directly; we snapshot them against golden images.
import Testing
@Suite("NotesListViewModel")
struct NotesListViewModelTests {
@Test("loads notes on start")
func loadsOnStart() async {
let provider = MockNotesProvider(notes: .samples)
let sut = NotesListViewModel(provider: provider)
await sut.load()
#expect(sut.notes.count == 3)
}
}
Refactoring a legacy iOS app to MVVM-C?
We run architecture assessments with a concrete refactor plan, cost, and risk register — typically 3 business days.
Book a 30-min assessment →MVVM-C vs VIPER vs TCA vs Clean Swift — decision matrix
| Pattern | Best fit | Boilerplate | Learning curve | Testability | SwiftUI fit |
|---|---|---|---|---|---|
| MVC (Apple) | ≤ 10 screens, 1 dev | Low | Trivial | Poor | Awkward |
| MVVM-C (ours) | 5–30 screens, 2–10 devs | Medium | 1–2 weeks | Excellent | Native (@Observable) |
| VIPER | 30+ screens, 10+ devs | Very high | 3–4 weeks | Excellent | Clunky |
| Clean Swift (VIP) | Banking, regulated UIs | High | 2–3 weeks | Excellent | Awkward |
| TCA | Deterministic state machines | Medium-high | 4–6 weeks | World-class | Excellent |
A decision framework — pick an architecture in 10 minutes
Work through these five questions in order; stop at the first clear answer.
Q1 — Team size and turnover. If more than 10 iOS engineers and any rotation, VIPER's strictness pays off. Otherwise continue.
Q2 — Determinism as a product feature. If the product is a trading app, multiplayer client, or complex wizard where "what state am I in?" must be exactly answerable, choose TCA. Otherwise continue.
Q3 — Minimum iOS target. If iOS 17+ only, SwiftUI-first MVVM with @Observable is lightest. If you need iOS 14/15 support, UIKit-MVVM-C with Combine. Otherwise continue.
Q4 — Existing codebase. If adopting an existing app, match its pattern before refactoring. A half-migrated app is worse than a consistent legacy one.
Q5 — Prototype vs production. Under two weeks of expected lifetime, plain MVC is fine. Anything shipping to real users, MVVM-C.
Mini case — EdTech video client, UIKit → MVVM-C migration
A series-B EdTech client engaged us in Q1 2025 with a three-year-old UIKit codebase: 42 screens, 8 iOS engineers, 18% crash-free-session rate deterioration, and an onboarding cost of three weeks per new engineer. The root cause on audit: 12,000-line ViewControllers with direct network calls, no ViewModels, and navigation logic scattered across prepare(for segue:).
We proposed a 14-week migration to MVVM-C, screen by screen, behind a feature flag. Each sprint we picked one vertical feature, extracted its Provider, wrote its ViewModel and tests, introduced the Coordinator, and cut the UIViewController down to pure rendering. The old path stayed live until the new one passed QA.
Outcomes at week 14: crash-free sessions 99.6% → 99.91%; new-engineer onboarding 21 days → 6 days; ViewModel branch coverage 0% → 84%; App Store rating 4.1 → 4.6 over the following quarter. The feature flag let product ship three revenue features in parallel with the migration.
2026 iOS architecture tooling stack
Swift 6 + Xcode 16 — strict concurrency, Swift Testing, improved macros. Non-negotiable for new projects.
SwiftLint + SwiftFormat with a team-shared .swiftlint.yml. Enforces MVVM boundaries via custom rules (e.g., "no UIKit import in files matching *ViewModel.swift").
Tuist or Swift Package Manager for modularisation. A five-module split (App, Features, Core, DesignSystem, Networking) cuts incremental build time by 3–5× on real projects.
Periphery for dead-code detection. Catches orphaned Providers and ViewModels that accumulate during refactors.
Sourcery or macros for mock generation. One annotation on a protocol, an entire mock class at build time.
swift-snapshot-testing for View layer safety. One line per snapshot, PR previews on every View change.
Instruments + MetricKit + Xcode Cloud for performance regression detection in CI.
Real-time, video, and WebRTC — where MVVM-C earns its keep
Real-time iOS clients push state harder than any other product category. A WebRTC call emits track-state, ICE-state, peer-connection-state, media-stats, and chat events at dozens of updates per second. Without isolation those become a massive web of delegate callbacks in a single ViewController. With MVVM-C the pattern is:
One actor per signalling channel. An actor SignalingClient owns the WebSocket. It exposes an AsyncStream of typed events; nothing else touches the socket.
Provider per subsystem. CallProvider bridges signalling → UI state. ChatProvider handles messages. DevicesProvider owns camera/mic selection. Each has its own @Published state.
ViewModels compose Providers. The in-call ViewModel subscribes to all three and produces a single ViewInputData. The View stays a thin renderer.
Coordinators own call lifecycle. CallCoordinator creates the triad for an incoming call and tears it down when the call ends — no leaked audio engines, no orphan peer connections. We have shipped video clients that hold this pattern through 90-minute group calls with zero memory growth.
When NOT to use MVVM-C
Architecture is a cost you pay for change. If your app will not change much, skip the architecture. Three scenarios where MVVM-C is the wrong answer:
Throwaway prototypes. A two-week App Clip or trade-show demo does not need Coordinators; a single SwiftUI App with two @Observable view models ships faster.
Single-developer indie apps under 10 screens. The overhead of DI containers and coordinator plumbing is real. A disciplined MVC-with-SwiftUI codebase by one engineer is perfectly maintainable.
Apps where determinism is the product. Trading, games, complex regulatory flows — use TCA and enjoy reducer-level tests and time-travel debugging. MVVM-C is not wrong here, but TCA is stronger.
Five pitfalls that quietly ruin MVVM-C
1. Fat ViewModels. If a ViewModel exceeds 300 lines it has taken on Provider responsibilities. Extract.
2. ViewControllers holding Providers. A UIViewController should never import a service directly. If it does, the Coordinator wiring leaked.
3. Coordinator god-object. A single AppCoordinator with 20 child-spawning methods is a Massive Coordinator. Split by feature flow.
4. DI singleton abuse. .perma scope for every service silently re-introduces global state. Prefer .objectGraph scope bound to a flow.
5. Ignoring @MainActor. Swift 6 will catch most concurrency bugs, but legacy bridges still dispatch to the wrong queue. Annotate ViewModels with @MainActor explicitly.
KPIs: what to measure once MVVM-C ships
The architecture is working when these numbers move. Track them every sprint.
Engineering KPIs. ViewModel branch coverage (target > 80%), new-engineer onboarding time to first shipped PR (target < 7 days), median PR review time (target < 24h), crash-free-session rate (target > 99.9%), time to add a new screen (target < 1.5 days including tests).
Product KPIs. Feature cycle time (spec → store) before vs after, defect-escape rate from QA to production (target < 5%), App Store rating over rolling 30 days (expect lift of 0.2–0.4 stars post-stabilisation), percentage of incidents caused by navigation bugs (expect drop to < 5% of total).
Compliance & quality KPIs. SwiftLint violations per 1 kLoC (target < 1), Periphery dead-code findings per release (target = 0), snapshot test drift per PR (target < 2 diffs), accessibility audit pass rate (target 100% per our iOS accessibility playbook).
Sum up
If you are starting a new iOS app in 2026, the answer is MVVM + Coordinators + DI, written in Swift 6 with strict concurrency, on iOS 17+ where SwiftUI's @Observable does most of the reactive plumbing. Fall back to UIKit-MVVM-C with Combine when you need older-OS support or fine-grained UIKit performance. Reach for VIPER only when team size and turnover demand the extra rigidity, and reach for TCA only when deterministic state is a product requirement. Across every choice, the same three levers — testable ViewModels, coordinated navigation, and injected dependencies — are what make an iOS app cheap to change two years from now.
Ready to start — or rescue — an iOS build?
Fora Soft senior iOS engineers ship in MVVM-C, Swift 6, and SwiftUI from day one. Typical engagement: 6–14 weeks.
Book a 30-min call →Frequently asked questions
Is MVVM still relevant with SwiftUI's @Observable?
Yes — @Observable is literally MVVM with less boilerplate. The ViewModel is a class annotated with @Observable; the View consumes it with @State or @Bindable. Apple engineers describe the pattern as "effectively MVVM" in WWDC sessions from 2023 onward.
When should a team prefer TCA over MVVM-C?
When determinism is a product requirement: multiplayer clients, trading apps, complex wizards with undo/redo, or anything where "what state am I in?" must be answerable exactly. TCA's reducer-as-state-machine model excels there and comes with world-class testing tooling. For everything else, MVVM-C ships faster with fewer abstractions.
RxSwift, Combine, or Swift Concurrency in 2026?
For new code: Swift Concurrency first (async/await, actors), Combine where you need declarative pipelines and back-pressure, RxSwift only when inherited. New projects starting today default to async/await + @Observable and pull in Combine only for the 10% of flows that need it.
Do coordinators add unnecessary complexity for small apps?
Under three flows and with no deep links, coordinators are overhead. The break-even point is roughly five screens or one reused-from-multiple-entries screen. Below that, let the ViewController push directly; above it, introduce a Coordinator and thank your future self.
Which DI library for SwiftUI-first apps?
Factory 2.x is the strongest fit: macro-based, zero dependencies, ergonomic property-wrapper API that matches SwiftUI's @Environment feel. DITranquillity remains excellent for UIKit and mixed codebases.
How do you handle deep links under MVVM-C?
The AppCoordinator owns deep-link routing: it parses the URL into a DeepLinkOption enum, finds or starts the relevant child Coordinator, and calls start(with: option). Child Coordinators know how to react to options that belong to their flow. This keeps URL parsing in one place and per-flow logic local.
Can we mix UIKit MVVM-C with SwiftUI screens?
Yes — this is the most common 2026 setup in mature apps. Wrap SwiftUI screens in UIHostingController inside the Coordinator; the ViewModel contract is identical, the binding syntax changes. We have shipped three apps this year with exactly this split: SwiftUI for new screens, UIKit for legacy, shared MVVM-C backbone.
Read next
Talk to a senior iOS architect
30 minutes, free, no sales. We will look at your codebase and give a straight answer on what to refactor first.
Book now →.png)


.avif)

Comments