iOS Architecture
Irbisa · cheatsheetSeptember 13, 2026

iOS Architecture

MVC, MVVM, VIPER, Coordinators, TCA, Clean Swift

Middle Developer20 itemscompressed for a skim
  1. 01

    Compare MVC, MVVM, VIPER, and TCA on iOS.

    Medium

    Every 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)" }
    …
  2. 02

    How do you avoid 'Massive View Controller' in UIKit?

    Medium

    A 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
    …
  3. 03

    What is the Coordinator pattern? When is it worth using?

    Medium

    A Coordinator is an object responsible for navigation.

    protocol Coordinator: AnyObject {
      var children: [Coordinator] { get set }
      func start()
    }
    
    final class AppCoordinator: Coordinator {
    …
  4. 04

    How would you inject dependencies into a view model so it can be unit-tested?

    Medium

    Constructor 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) } }
    …
  5. 05

    What is The Composable Architecture (TCA) and what problems does it solve?

    Hard

    TCA 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 {
    …
  6. 06

    How do you organize a large iOS codebase? Modules, frameworks, SPM packages.

    Medium

    Split 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"])],
    …
  7. 07

    A push notification must open a specific comment on a specific post. Where does that routing state live?

    Medium

    Deep 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
    }
    …
  8. 08

    Does an iOS app really need repositories and use cases, or is a service class enough?

    Medium

    A 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
    }
    …
  9. 09

    Some argue MVVM is redundant in SwiftUI. What is the argument, and where do you land?

    Medium

    The 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. 10

    You are adding SwiftUI to a ten-year-old UIKit app. Where do you draw the boundary?

    Medium

    Adopt 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. 11

    The app is 300k lines of view controllers and feature work cannot stop. How do you reach a testable architecture?

    Hard

    Refactor 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. 12

    Delegate, closure, notification or observation: how do you choose how two objects talk?

    Easy

    Choose 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. 13

    A reviewer blocks your pull request because your view model imports UIKit. What is the concrete harm?

    Easy

    A 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. 14

    A comment cell shows "3 hours ago". Should the model, the view model or the view produce that string?

    Easy

    The 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. 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?

    Medium

    Collapse 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 == true and error != nil at 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. 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?

    Medium

    The 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. 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?

    Medium

    Resolve 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. 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?

    Medium

    It 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. 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?

    Hard

    Isolate the UI layer to the main actor, leave the domain and data layers unisolated or actor-isolated, and let Sendable values 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. 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?

    Hard

    In 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 {
    …