12.3 — Clean Architecture, VIP & VIPER
Opening scenario
A staff engineer at a fintech walks you through their codebase. Every feature is a folder with eight files: LoginViewController, LoginPresenter, LoginInteractor, LoginRouter, LoginEntity, LoginWorker, LoginAssembler, LoginConfigurator. You ask: “How long to add a ‘forgot password’ link?” Answer: “Three days.” This chapter is about when that ceremony is worth it — and when it’s malpractice.
Context — the heavy-architecture family
| Pattern | Origin | Layers | Ideal scale |
|---|---|---|---|
| Clean Architecture | Uncle Bob (2012) | Entity, UseCase, Interface Adapter, Framework | Domain-heavy apps |
| VIP (View-Interactor-Presenter) | Raymond Law (Clean Swift) | View, Interactor, Presenter, Router, Worker | Enterprise iOS |
| VIPER | Mutual Mobile (2014) | View, Interactor, Presenter, Entity, Router | Banking, healthcare |
| RIBs | Uber (2017) | Router, Interactor, Builder | Hundreds of engineers |
These all share an ethos: separate the business rules from the framework. They differ in how strictly they enforce it.
What Clean Architecture actually is
Uncle Bob’s diagram has four rings. Inner depends on no outer. iOS-adapted:
| Ring | Examples | Knows about |
|---|---|---|
| Entities | Account, Transaction | Pure Swift only |
| Use Cases | TransferFunds, ListTransactions | Entities, repository protocols |
| Interface Adapters | TransactionPresenter, AccountRepository impl | Use Cases, framework types |
| Frameworks & Drivers | UIKit, CoreData, URLSession | Everything |
The boundary is enforced by protocols pointing inward (dependency inversion). The Use Case defines a AccountRepositoryProtocol; CoreData implements it.
Concept → Why → How → Code
Concept: business rules become executable specifications that read like the product spec, with frameworks plugged in behind protocols.
Why: testability without a host app, frameworks swappable (CoreData → SwiftData), and the codebase still makes sense after 10 years of staff turnover.
How — minimal Clean Swift skeleton:
// MARK: Entity (innermost)
struct Account {
let id: UUID
var balanceCents: Int
}
// MARK: Use Case
protocol AccountRepository {
func load(_ id: UUID) async throws -> Account
func save(_ account: Account) async throws
}
struct TransferFunds {
let repo: AccountRepository
func execute(from: UUID, to: UUID, cents: Int) async throws {
guard cents > 0 else { throw TransferError.nonPositive }
var src = try await repo.load(from)
var dst = try await repo.load(to)
guard src.balanceCents >= cents else { throw TransferError.insufficient }
src.balanceCents -= cents
dst.balanceCents += cents
try await repo.save(src)
try await repo.save(dst)
}
enum TransferError: Error { case nonPositive, insufficient }
}
// MARK: Interface Adapter (Presenter)
@Observable @MainActor
final class TransferPresenter {
enum State { case idle, working, success, error(String) }
private(set) var state: State = .idle
private let useCase: TransferFunds
init(useCase: TransferFunds) { self.useCase = useCase }
func transfer(from: UUID, to: UUID, cents: Int) async {
state = .working
do {
try await useCase.execute(from: from, to: to, cents: cents)
state = .success
} catch {
state = .error(error.localizedDescription)
}
}
}
// MARK: Framework (CoreData impl)
final class CoreDataAccountRepository: AccountRepository {
func load(_ id: UUID) async throws -> Account { /* fetch NSManagedObject, map */ fatalError() }
func save(_ account: Account) async throws { /* mutate, save context */ }
}
The TransferFunds use case has zero UIKit/CoreData imports. You can unit-test it with a mock repository in 5 lines.
VIP / VIPER differences
| Concern | Clean Swift (VIP) | VIPER |
|---|---|---|
| Communication | Unidirectional (V → I → P → V) | Bidirectional via protocols |
| Routing | Router class | Router class (more central) |
| Boilerplate per feature | High (5–6 files) | Very high (7–8 files) |
| Mainstream uptake (2026) | Low | Declining; mostly legacy code |
Both pre-date modern Swift concurrency. In greenfield 2026 work, you’d reach for Clean Architecture with async/await rather than VIPER’s Wireframe → Router → Presenter ceremony.
In the wild
- Uber built RIBs (a VIPER cousin) for the Rider/Driver apps with thousands of engineers; open-sourced 2017. The justification is modularization at extreme scale, not architectural purity.
- Square Cash publicly discussed using a Clean-ish architecture with KMP shared business logic.
- Most banking apps (Chase, Wells Fargo, Monzo internally) use heavy patterns because regulatory testing requires the business rules to be independently verifiable.
Common misconceptions
- “VIPER is just VIPER.” No — every shop’s VIPER is custom. Without a team-wide template, every feature becomes its own dialect.
- “Clean Architecture means more files = more clean.” Cleanliness is about dependency direction, not file count.
- “You can’t use SwiftUI with Clean Architecture.” You can — the View ring just becomes SwiftUI views observing the Presenter.
- “Use cases must be classes.” Often a
structwith one method is cleaner. - “Clean Architecture is overkill for everything under 1M users.” Not user count — domain complexity. A 1k-user healthcare app may need it; a 10M-user wallpaper app does not.
Seasoned engineer’s take
Heavy architectures are insurance policies: you pay premiums (boilerplate, onboarding cost, slower iteration) for protection against future change. Pay the premium when the domain is genuinely complex (banking, insurance, healthcare, multi-platform via KMP) or when teams will scale past ~25 iOS engineers. Don’t pay it for a content app with five screens.
TIP: If you’re tempted by VIPER, first try MVVM + Coordinators. 80 % of the testability win, 30 % of the boilerplate.
WARNING: A half-applied Clean Architecture is worse than MVC. If you don’t enforce the dependency rule everywhere, you’ve just added boilerplate without the testability payoff.
Interview corner
Junior: “What’s the difference between VIPER and MVVM?” VIPER splits the screen into View, Interactor (business logic), Presenter (display formatting), Entity (data), Router (navigation). MVVM has only View + ViewModel + Model. VIPER trades more files for stricter separation.
Mid: “Why would you choose Clean Architecture over MVVM?” When the business logic must be testable independently of UI frameworks, when the same domain might run on iOS + macOS + a backend (Kotlin Multiplatform), or when a team needs strict layer boundaries to scale safely. The cost is more files per feature.
Senior: “How would you adopt Clean Architecture gradually in an existing UIKit MVC app?”
I’d start by extracting use cases from the most-changed View Controllers — leave the VCs as-is but route their logic through a Repository protocol + UseCase struct. That gives me unit-testable business rules without rewriting the View layer. Once those are stable, I’d introduce Presenters between use cases and VCs only where state formatting is non-trivial. I wouldn’t introduce Routers until navigation logic becomes a bottleneck. The point is incremental dependency inversion, not a Big Rewrite.
Red-flag answer: “We rewrote everything to VIPER and it’s much better now” — without metrics, without acknowledging the cost. Senior engineers can point to specific bugs avoided or velocity changes.
Lab preview
No dedicated lab for Clean/VIPER — Lab 12.2 (modularize a monolith) covers the physical separation that makes these patterns enforceable. The conceptual exercise here is to imagine refactoring your favorite small app to VIPER, count the new files, and decide if you’d actually ship it.