Senior / Staff iOS — interview prep
Architecture & patterns
MVVM · coordinators · DI · repositories · modules · SSOT · flags
MVVMCoordinatorsDIRepositorySPMSSOTClean-ishLoad stateFeature flags
01
MVVM — boundaries & observation
Q1View model
- Presentation state, formatting, user actions, async commands
- No direct
UILabelownership - Protocols + injection for tests
View / VC
- Lifecycle, layout, animations, wiring
- Forwards events; renders bindings
Observable vs ObservableObject
ObservableObject+@Published→ Combine,objectWillChange@Observablemacro → 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
Q2Routes + 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.sharedsprawl
03
Dependency injection
Q3Constructor 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
Q4i
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
Q5Dependency direction
// Intended imports (Features → Domain → Infrastructure)
// SearchFeature → SearchDomain → Networking
// SearchFeature must not import CheckoutFeature.
Split
- Vertical features + shared Core / DesignSystem
- Clear
publicAPI per target
Avoid
- Cycles between Feature A ↔ B
- Kitchen-sink
Utilspackage
06
Single source of truth
Q6One 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 · Q8Pragmatic 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
Q9Unified 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+errorbools - 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
Q10Factory at composition root
protocol FeatureFlags {
var newCheckout: Bool { get }
}
func makeCheckout(flags: FeatureFlags) -> CheckoutFlow {
flags.newCheckout ? NewCheckoutFlow() : LegacyCheckoutFlow()
}
- Central
FeatureFlagsmerges 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| Topic | Rule of thumb | Smell |
|---|---|---|
| MVVM | VM = state + intent; view = chrome | 3k-line “VM” that is the old VC |
| Navigation | Coordinator owns flow; VC doesn’t new-up random next VC | Push logic copy-pasted everywhere |
| DI | init(protocols) at boundaries | Global singletons in every type |
| Data | Repository dedupes + caches | URLSession in each VM |