← All topics/Architecture & design patterns

Senior / Staff iOS — interview prep

Architecture & patterns

MVVM · coordinators · DI · repositories · modules · SSOT · flags

MVVMCoordinatorsDIRepositorySPMSSOTClean-ishLoad stateFeature flags
01

MVVM — boundaries & observation

Q1
View model
  • Presentation state, formatting, user actions, async commands
  • No direct UILabel ownership
  • Protocols + injection for tests
View / VC
  • Lifecycle, layout, animations, wiring
  • Forwards events; renders bindings
Observable vs ObservableObject
  • ObservableObject + @Published → Combine, objectWillChange
  • @Observable macro → synthesized tracking; often less boilerplate in new SwiftUI
UIKit — thin VC
protocol ProfileVM: AnyObject {
    var title: String { get }
    func load()
}

final class ProfileViewController: UIViewController {
    private let vm: ProfileVM
    init(vm: ProfileVM) { self.vm = vm; super.init(nibName: nil, bundle: nil) }
    // bind UI; forward actions only
}
SwiftUI — observation styles
// Observation — synthesized tracking on stored properties
@Observable @MainActor
final class FeedModel {
    private(set) var items: [Item] = []
    func refresh() async { /* ... */ }
}

// Combine — @Published + objectWillChange
final class LegacyFeedModel: ObservableObject {
    @Published private(set) var items: [Item] = []
}
02

Coordinators & navigation

Q2
Routes + coordinator protocol
enum AppRoute: Hashable {
    case profile(id: String)
    case settings
}

protocol AuthCoordinating: AnyObject {
    func go(to route: AppRoute)
    func dismissAuth()
}
Owns stack
  • Creates VCs / roots; injects deps
  • Child coordinators for sub-flows
Deep links
  • Parse URL → tab + push on root coordinator
  • Avoid UIApplication.shared sprawl
03

Dependency injection

Q3
Constructor injection + composition root
final class CheckoutViewModel {
    private let cart: CartServing
    private let payments: PaymentProcessing

    init(cart: CartServing, payments: PaymentProcessing) {
        self.cart = cart
        self.payments = payments
    }
}

// Composition root (App / Scene) constructs concrete types once
!
Service locator (standard pattern: a registry you look up deps from—often a .shared singleton) hides what a type needs and hurts tests. Prefer explicit init at the composition root—not the same as “any singleton” (e.g. URLSession.shared is a singleton API, not a locator for all your types).
04

Repositories & use cases

Q4
i
Repository = one type your feature asks for data (e.g. fetchProfile()), instead of each view model calling URLSession / Core Data directly. It decides cache vs network and keeps those rules in one place per area (users, feed, …)—not a magic database, just a coordinator for how that data is loaded.
Façade: “give me a User” — not raw HTTP everywhere
// App code asks the repository for “a User”, not “a GET /users/:id”
protocol UserRepository {
    func user(id: String) async throws -> User
}

final class LiveUserRepository: UserRepository {
    func user(id: String) async throws -> User {
        // try cache → else network → decode DTO → map to User
        // dedupe in-flight requests with the same id
    }
}
Repository
  • Hides where data comes from (RAM, disk, API) behind one API
  • Dedupe in-flight fetches; TTL; invalidation policy
  • DTO decoding near I/O; map to domain types before the VM
Use case
  • Add when one action composes multiple repos or business rules repeat
  • Not every button needs its own interactor file
05

Feature modules & SPM

Q5
Dependency direction
// Intended imports (Features → Domain → Infrastructure)
// SearchFeature → SearchDomain → Networking
// SearchFeature must not import CheckoutFeature.
Split
  • Vertical features + shared Core / DesignSystem
  • Clear public API per target
Avoid
  • Cycles between Feature A ↔ B
  • Kitchen-sink Utils package
06

Single source of truth

Q6
One store; screens observe
@Observable @MainActor
final class SessionStore {
    private(set) var currentUser: User?

    func apply(_ user: User) {
        currentUser = user  // one writer; screens read / observe
    }
}
i
After a mutation, update the canonical store first—then lists and detail stay aligned. Key entities by stable id, not row index.
07

Clean layers & massive VC

Q7 · Q8
Pragmatic clean
  • Domain + pure functions where rules are complex
  • Push UIKit types to outer layers
  • Interactors when policy repeats—not per tap
Strangler refactor
  • Extract networking / analytics / data source one protocol at a time
  • Child VCs or hosting sections for layout
  • Feature-flag new VM path if risky
08

Loading, empty & errors

Q9
Unified async UI state
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)  // or map Error → user message elsewhere
}

// One state machine → skeleton / retry / empty UI
One machine
  • Avoid competing isLoading + error bools
  • Map errors to user copy in one place
Cross-cutting
  • Shared toast / modal policy—not 20 alert copies
  • Match VoiceOver to the same states
09

Feature flags

Q10
Factory at composition root
protocol FeatureFlags {
    var newCheckout: Bool { get }
}

func makeCheckout(flags: FeatureFlags) -> CheckoutFlow {
    flags.newCheckout ? NewCheckoutFlow() : LegacyCheckoutFlow()
}
  • Central FeatureFlags merges remote + local QA overrides
  • Log variant for analytics; test both paths in CI
  • Delete flag branches after rollout—flags are debt
10

Quick checklist

Q1–Q10
TopicRule of thumbSmell
MVVMVM = state + intent; view = chrome3k-line “VM” that is the old VC
NavigationCoordinator owns flow; VC doesn’t new-up random next VCPush logic copy-pasted everywhere
DIinit(protocols) at boundariesGlobal singletons in every type
DataRepository dedupes + cachesURLSession in each VM
Architecture & design patterns — interview prep cheat sheetAligned with practical question set