iOS Architecture
MVC, MVVM, VIPER, Coordinators, TCA, Clean Swift
- 01
Compare MVC, MVVM, VIPER, and TCA on iOS.
MediumEvery iOS architecture is a different answer to one question: where does the logic that is not view code live?
// MVVM with SwiftUI @Observable final class ProfileVM { var user: User? var error: String? func reload() async { do { user = try await api.fetch() } catch { self.error = "\(error)" } … - 02
How do you avoid 'Massive View Controller' in UIKit?
MediumA view controller should orchestrate, not implement: everything that is not wiring — presentation logic, data sources, navigation, networking — belongs in a collaborator you can test on its own.
// Before — fat VC class ProfileVC: UIViewController, UITableViewDataSource { // owns model, network, table data source, navigation — too much } // After — split responsibilities … - 03
What is the Coordinator pattern? When is it worth using?
MediumA Coordinator is an object responsible for navigation.
protocol Coordinator: AnyObject { var children: [Coordinator] { get set } func start() } final class AppCoordinator: Coordinator { … - 04
How would you inject dependencies into a view model so it can be unit-tested?
MediumConstructor injection is the default answer on iOS: declare collaborators as protocols, take them in
init, and let a composition root at the app entry point decide which implementations are used.// Define abstractions protocol UserAPI { func fetch(id: String) async throws -> User } protocol AnalyticsClient { func track(_ event: String) } // Production impls struct ProdUserAPI: UserAPI { func fetch(id: String) async throws -> User { /* HTTP */ User(id: id) } } … - 05
What is The Composable Architecture (TCA) and what problems does it solve?
HardTCA is a Redux-style library from Point-Free that models a feature as data: state in a struct, every event in an Action enum, and a pure reducer that turns one into the other.
import ComposableArchitecture @Reducer struct Counter { @ObservableState struct State: Equatable { … - 06
How do you organize a large iOS codebase? Modules, frameworks, SPM packages.
MediumSplit the app into local Swift Package modules and keep the app target a thin shell that only wires them together.
import PackageDescription let package = Package( name: "Profile", platforms: [.iOS(.v17)], products: [.library(name: "Profile", targets: ["Profile"])], … - 07
A push notification must open a specific comment on a specific post. Where does that routing state live?
MediumDeep links work reliably only when navigation is state you own, not a chain of pushes scattered across views.
enum Route: Hashable { case post(id: String) case comment(postID: String, commentID: String) case settings } … - 08
Does an iOS app really need repositories and use cases, or is a service class enough?
MediumA repository earns its place as soon as more than one source can answer the same question, while a use-case type earns its place only when a workflow spans several repositories.
// Domain — no framework types, trivially testable struct Article: Identifiable, Equatable { let id: String let title: String let body: String } … - 09
Some argue MVVM is redundant in SwiftUI. What is the argument, and where do you land?
MediumThe case against is that a SwiftUI view is already a value-typed view model, so an extra class that only forwards state adds indirection without adding a testable seam.
// Plain screen — no view model needed, and none added struct CounterView: View { @State private var count = 0 var body: some View { Stepper("Count \(count)", value: $count, in: 0...10) … - 10
You are adding SwiftUI to a ten-year-old UIKit app. Where do you draw the boundary?
MediumAdopt SwiftUI one whole screen at a time behind a
UIHostingController, so the seam falls on a navigation edge instead of inside a view.// UIKit pushes a SwiftUI screen; the coordinator still owns navigation final class SettingsCoordinator { private let nav: UINavigationController private let session: SessionStore // @Observable, shared by both worlds init(nav: UINavigationController, session: SessionStore) { … - 11
The app is 300k lines of view controllers and feature work cannot stop. How do you reach a testable architecture?
HardRefactor along the seams you are already touching: new work is written in the target architecture and old screens change only when a ticket lands in them.
// Before — untestable: the dependency is fetched, not received final class FeedViewController: UIViewController { private var posts: [Post] = [] func load() { Task { posts = (try? await NetworkManager.shared.feed()) ?? [] } … - 12
Delegate, closure, notification or observation: how do you choose how two objects talk?
EasyChoose by the shape of the link: several messages to one known counterpart is a delegate, a single result is an async call, and many unknown listeners means a notification or an observable.
// Delegate — long-lived, several messages, one known counterpart protocol ScannerDelegate: AnyObject { func scanner(_ scanner: Scanner, didFind code: String) func scannerDidFail(_ scanner: Scanner, error: Error) } … - 13
A reviewer blocks your pull request because your view model imports UIKit. What is the concrete harm?
EasyA view model that imports UIKit starts deciding how things look instead of what to show, and it drags main-actor isolation, extension-unsafe APIs and platform lock-in in with it.
// Wrong — the view model decides appearance and reaches for app-level state import UIKit final class OrderStatusViewModel { var statusColor: UIColor = .systemGray // a design tweak now edits logic weak var label: UILabel? // a view controller in disguise … - 14
A comment cell shows "3 hours ago". Should the model, the view model or the view produce that string?
EasyThe model keeps a
Date, the view model or the view turns it into text at render time — a pre-baked string stored in the domain model is the one arrangement that is always wrong.// Wrong — the string is baked into the model when the JSON is decoded struct Comment { let id: String let body: String let postedAgo: String // "3 hours ago": stale in a minute, wrong after } // a language change, impossible to test … - 15
Your view model exposes isLoading, error and items, and QA sees a spinner drawn on top of an error message. What is the real fix?
MediumCollapse the parallel flags into one enum so the illegal combinations cannot be expressed — the bug is not in the view, it is that the type allows
isLoading == trueanderror != nilat the same time.// Wrong — 8 representable combinations for a screen that has 4 states @Observable final class FeedViewModel { var isLoading = false var error: Error? var items: [Item] = [] … - 16
The backend renamed one JSON field and the change rippled into three SwiftUI views. What is wrong with the layering, and when is a single model actually fine?
MediumThe wire format is being used as the app's domain model, so every server decision is a UI change — the fix is a DTO that only the networking layer knows and a domain type the rest of the app speaks.
// The wire format — pessimistic, matches the server exactly struct PostDTO: Decodable { let id: Int let author_name: String? let created_at: String let status: String // never an enum here … - 17
A release crashed on launch with "no registration for AuthService" from the DI container. How do you make that whole class of bug impossible?
MediumResolve the graph at compile time by constructing objects through initialisers at a composition root, instead of asking a runtime registry for a type and trusting that somebody registered it.
// Wrong — a runtime registry; a missing line fails only when the code runs let container = Container() container.register(AuthService.self) { _ in LiveAuthService() } final class ProfileViewModel { private let auth: AuthService … - 18
Your team's Clean Swift screens run a ViewController to Interactor to Presenter to ViewController cycle. What does that one-way loop buy, and what does it cost?
MediumIt buys a dependency chain that only points one way, with a protocol at each hop you can substitute in a test, and it costs five files and three model types for every screen — including the trivial ones.
// Clean Swift scene: VC -> Interactor -> Presenter -> VC, one direction only protocol OrderBusinessLogic { func fetchOrder(request: Order.Fetch.Request) } protocol OrderPresentationLogic { func presentOrder(response: Order.Fetch.Response) } protocol OrderDisplayLogic: AnyObject { func displayOrder(viewModel: Order.Fetch.ViewModel) } enum Order { // three model types per use case … - 19
Swift 6 strict concurrency lit up your view models with data-race errors. Which layers get @MainActor, and what breaks if you mark everything?
HardIsolate the UI layer to the main actor, leave the domain and data layers unisolated or actor-isolated, and let
Sendablevalues cross between them — the errors are pointing at mutable state that has no owner, not at a missing annotation.// UI layer — isolated to the main actor because that is where its state is read @MainActor @Observable final class FeedViewModel { private(set) var posts: [Post] = [] // Post is a Sendable value type private let repo: PostRepositoryProtocol … - 20
A three-step checkout shares one draft order, and users report a new checkout opening with the previous order's address. Where should that draft live?
HardIn an object owned by the flow itself, created when the flow starts and released when it ends — a stale address means the draft outlives the flow, which is exactly what a singleton or an app-root environment object guarantees.
// Wrong — app-wide lifetime, so the next checkout inherits the last one final class CheckoutDraft { static let shared = CheckoutDraft() } // Right — the draft's lifetime is the flow's lifetime @Observable final class CheckoutDraft { …