Architecture
MVC, MVVM, VIPER/Clean and unidirectional flow; the Coordinator and Repository patterns, dependency injection, SOLID, and modularizing an app into packages.
14 questions
JuniorTheoryVery commonIn Apple's UI pattern MVC, what belongs in the view controller, and why does it grow massive?
In Apple's UI pattern MVC, what belongs in the view controller, and why does it grow massive?
The controller mediates between model and views — it owns the view lifecycle, wires outlets and actions, and formats data for display. It grows massive because MVC gives networking, persistence, and data-source code no home, so it all piles in.
Common mistakes
- ✗Believing MVC defines a dedicated layer for networking or persistence
- ✗Putting model business logic inside the controller instead of the model
- ✗Blaming storyboards rather than un-homed code for the bloat
Follow-up questions
- →Where would you move data-source and networking code to slim the controller?
- →How does the UI pattern
MVVMchange what the view controller is responsible for?
JuniorTheoryVery commonIn MVVM, what is the view model responsible for, what must it never import, and how does the view bind to it?
In MVVM, what is the view model responsible for, what must it never import, and how does the view bind to it?
The view model holds presentation state and logic — turning model data into display-ready values it exposes. It must never import UIKit or SwiftUI, so it stays framework-agnostic and testable. The view binds to it and re-renders when its published state changes.
Common mistakes
- ✗Letting the view model import UIKit or SwiftUI and lose testability
- ✗Putting presentation transformation in the controller instead of the view model
- ✗Having the view read the view model manually instead of observing it
Follow-up questions
- →Why does keeping UIKit out of the view model make it unit-testable?
- →How does the view model differ from the controller in classic MVC?
JuniorTheoryCommonDependency injection — constructor vs property vs a container: why is DI what makes code testable?
Dependency injection — constructor vs property vs a container: why is DI what makes code testable?
An object receives its collaborators from outside instead of creating them. Constructor injection passes them into init, property injection sets them later, a container resolves them centrally. DI makes code testable because a test can swap in mocks for real dependencies.
Common mistakes
- ✗Equating DI with a global singleton or service locator
- ✗Thinking a DI container is required rather than plain constructor injection
- ✗Believing DI is about performance rather than substitutability
Follow-up questions
- →When would you prefer property injection over constructor injection?
- →What are the downsides of resolving dependencies through a container?
JuniorTheoryCommonDelegate, closure, NotificationCenter, reactive Combine/async — which object-communication style fits which case?
Delegate, closure, NotificationCenter, reactive Combine/async — which object-communication style fits which case?
A delegate fits a one-to-one contract where a callee reports back to one owner. A closure fits a short, local, one-shot callback. NotificationCenter fits one-to-many broadcast to unknown listeners. Combine or async fits streams of values and composable asynchronous work.
Common mistakes
- ✗Using NotificationCenter for one-to-one flow where a delegate is clearer
- ✗Reaching for Combine on a single one-shot callback a closure would handle
- ✗Treating a delegate and a broadcast notification as interchangeable
Follow-up questions
- →Why can
NotificationCentermake data flow hard to trace compared with a delegate? - →When would you choose async/await over a delegate callback?
JuniorTheoryCommonWhat is the observer pattern, where does iOS already use it, and how is it different from a delegate?
What is the observer pattern, where does iOS already use it, and how is it different from a delegate?
The observer pattern lets one subject notify many registered observers on a state change without knowing who they are. iOS uses it in NotificationCenter, KVO, and Combine. A delegate differs by being one-to-one — a single named callback — not a broadcast.
Common mistakes
- ✗Confusing observer's one-to-many broadcast with a delegate's one-to-one link
- ✗Thinking observers must subclass the subject they watch
- ✗Believing NotificationCenter is not an instance of the observer pattern
Follow-up questions
- →Why can a broadcast observer make data flow harder to trace than a delegate?
- →When would you pick a delegate over
NotificationCenterfor callbacks?
MiddleDesignCommonYour view controllers push and present each other directly, so navigation logic is scattered and screens cannot be reused or deep-linked. You adopt the Coordinator navigation pattern to own flow. Explain what the coordinator takes over from the view controllers, how a child screen passes a result back to its coordinator, and how an incoming deep link drives the same flow the UI would.
Your view controllers push and present each other directly, so navigation logic is scattered and screens cannot be reused or deep-linked. You adopt the Coordinator navigation pattern to own flow. Explain what the coordinator takes over from the view controllers, how a child screen passes a result back to its coordinator, and how an incoming deep link drives the same flow the UI would.
The coordinator owns navigation — it builds screens, injects dependencies, and picks what comes next, so view controllers no longer know each other. A child reports its result back through a delegate or closure, not by pushing directly. A deep link calls the same coordinator methods, driving the identical flow.
Common mistakes
- ✗Putting business logic in the coordinator instead of navigation
- ✗Having child screens navigate themselves rather than report back
- ✗Handling deep links outside the coordinator, duplicating flow logic
Follow-up questions
- →Why route a child's result through a delegate rather than a direct push?
- →How does a coordinator make one screen reusable across different flows?
MiddleDesignCommonYou inherit a 1,500-line view controller that mixes networking, a table data source, input validation, and navigation in one file. The team must keep shipping features weekly, so a full rewrite is ruled out, and there is no test suite yet. Describe how you break the controller up incrementally — what you extract first, where the extracted code goes, and how you keep the app releasable after every step.
You inherit a 1,500-line view controller that mixes networking, a table data source, input validation, and navigation in one file. The team must keep shipping features weekly, so a full rewrite is ruled out, and there is no test suite yet. Describe how you break the controller up incrementally — what you extract first, where the extracted code goes, and how you keep the app releasable after every step.
Extract in safe slices, not a rewrite. Move the data source and networking into their own types first — biggest, most testable, lowest-risk. Add characterization tests as you extract, point the controller at the new types, and ship after each step. A view model comes last.
Common mistakes
- ✗Attempting a big-bang rewrite instead of incremental extraction
- ✗Extracting UI before the higher-value networking and data-source code
- ✗Refactoring untested code without adding characterization tests first
Follow-up questions
- →Why extract the data source before introducing a full view model?
- →How do characterization tests let you refactor code that has no specs?
JuniorTheoryOccasionalWhich Gang-of-Four (GoF) patterns appear on iOS — one example each of singleton, factory, adapter, facade, decorator?
Which Gang-of-Four (GoF) patterns appear on iOS — one example each of singleton, factory, adapter, facade, decorator?
GoF patterns name recurring object designs. On iOS singleton is URLSession.shared, factory a maker like UIButton(type:), adapter wraps a foreign API behind a protocol, facade is one entry over a subsystem, decorator adds behaviour by wrapping.
Common mistakes
- ✗Thinking design patterns are language keywords rather than object structures
- ✗Believing every pattern requires subclassing rather than composition
- ✗Treating GoF patterns as Objective-C-only or deprecated in Swift
Follow-up questions
- →Why is
URLSession.sharedasingletonrather than a global function? - →When does an
adapterbeat changing the foreign type directly?
MiddleTheoryOccasionalThe behavioral observer pattern in practice — ownership and threading traps of NotificationCenter, KVO, Combine?
The behavioral observer pattern in practice — ownership and threading traps of NotificationCenter, KVO, Combine?
NotificationCenter posts synchronously on the poster's thread, so UI observers must hop to main, and block observers leak unless removed. KVO needs an @objc dynamic key path and can crash on double-removal. Combine cancels through a stored AnyCancellable.
Common mistakes
- ✗Assuming a notification is delivered on the main thread by default
- ✗Forgetting to remove a block-based NotificationCenter observer
- ✗Thinking KVO works without @objc dynamic on the observed key path
Follow-up questions
- →Why does a block-based
NotificationCenterobserver leak without an explicit removal? - →Why must a
KVOkey path be declared@objc dynamic?
MiddleDesignOccasionalA global AnalyticsManager.shared singleton is referenced directly from 40 files across the app. It makes those types impossible to unit-test in isolation and hides their real dependencies. You cannot change all 40 call sites in one pull request. Describe how you migrate to injected dependencies incrementally — what you introduce first, and how old and new code keep working side by side during the transition.
A global AnalyticsManager.shared singleton is referenced directly from 40 files across the app. It makes those types impossible to unit-test in isolation and hides their real dependencies. You cannot change all 40 call sites in one pull request. Describe how you migrate to injected dependencies incrementally — what you introduce first, and how old and new code keep working side by side during the transition.
Define a protocol the singleton conforms to and inject it through initializers. Migrate one type at a time, passing the shared instance at the call site so behaviour is unchanged. Keep .shared as a default argument during the switch; tests inject a fake, and drop .shared once the last caller moves over.
Common mistakes
- ✗Trying to remove the singleton in a single big-bang change
- ✗Replacing a singleton with a service locator and calling it DI
- ✗Believing subclassing the singleton in tests removes the coupling
Follow-up questions
- →Why does keeping
.sharedas a default argument ease the transition? - →How does injecting a protocol make the dependency visible in the initializer?
MiddleDesignOccasionalAn ImageDownloader class fetches bytes over the network, caches them to disk, decodes them into a UIImage, and renders them into a view — all in one type. Which of the SOLID design principles does this design violate, and what does a cleaner one look like? Name the responsibilities you would separate and how they would depend on one another.
An ImageDownloader class fetches bytes over the network, caches them to disk, decodes them into a UIImage, and renders them into a view — all in one type. Which of the SOLID design principles does this design violate, and what does a cleaner one look like? Name the responsibilities you would separate and how they would depend on one another.
It breaks single-responsibility — four jobs in one type — and with it open/closed and dependency-inversion, since nothing varies alone. Split it into a fetcher, a cache, a decoder, and a view, each depending on the next through a small protocol, so any one can be swapped or tested alone.
Common mistakes
- ✗Reading it as only a performance problem, not a design one
- ✗Naming the wrong SOLID principle for a responsibility overload
- ✗Splitting responsibilities but coupling them to concretes, not protocols
Follow-up questions
- →Why does one type with four jobs also break the open/closed principle?
- →How does depending on a protocol let you swap the cache in a test?
SeniorDesignOccasionalYou are choosing the presentation architecture a 30-engineer team will build on for years. The candidates are Apple's MVC, MVVM, VIPER, and unidirectional approaches like Clean or The Composable Architecture (TCA). Pick one and defend it against the others on testability, onboarding cost, boilerplate, and how well it scales to many teams working in parallel. State the trade-off you are knowingly accepting.
You are choosing the presentation architecture a 30-engineer team will build on for years. The candidates are Apple's MVC, MVVM, VIPER, and unidirectional approaches like Clean or The Composable Architecture (TCA). Pick one and defend it against the others on testability, onboarding cost, boilerplate, and how well it scales to many teams working in parallel. State the trade-off you are knowingly accepting.
For a large team, pick MVVM with coordinators: presentation logic is testable, most iOS engineers know it, and boilerplate is modest. MVC scales badly across teams; VIPER and TCA are more decoupled but cost onboarding and boilerplate. The trade-off is less structure than VIPER/TCA for speed and familiarity.
Common mistakes
- ✗Claiming MVC is the most testable because it has the least code
- ✗Treating more layers (VIPER) as strictly better regardless of cost
- ✗Ignoring onboarding and parallel-team scaling in the decision
Follow-up questions
- →Why does
MVCscale worse thanMVVMwhen many teams work in parallel? - →What onboarding cost do
VIPERandTCAadd overMVVM?
SeniorDesignOccasionalYou are structuring an app with the layered approach Clean Architecture so the business rules stay independent of UIKit, the network, and the database. Where do the layer boundaries go, which way do dependencies point across them, and what — concretely — is the domain layer allowed to import? Explain how the domain calls out to the network or database without depending on either, using the repository idea.
You are structuring an app with the layered approach Clean Architecture so the business rules stay independent of UIKit, the network, and the database. Where do the layer boundaries go, which way do dependencies point across them, and what — concretely — is the domain layer allowed to import? Explain how the domain calls out to the network or database without depending on either, using the repository idea.
Layers run domain, presentation, then data. Dependencies point inward only — outer knows inner, never the reverse. The domain imports nothing framework-specific: no UIKit, no URLSession, no Core Data. It declares the repository protocols it needs, which the data layer implements at runtime.
Common mistakes
- ✗Letting the domain layer import UIKit, URLSession, or Core Data
- ✗Pointing dependencies outward instead of inward across layers
- ✗Skipping repository protocols and calling data concretes from the domain
Follow-up questions
- →Why must the domain declare the repository protocol rather than the data layer?
- →How does inward-only dependency flow keep business rules framework-agnostic?
SeniorDesignOccasionalA single-target app has grown to hundreds of files and the team wants to split it into feature modules (Swift packages) to speed up builds and enforce ownership. Where do you draw the module boundaries, what shared layer do features depend on, and what technically prevents feature A from importing feature B directly? Explain how one feature exposes something another needs without a direct dependency on it.
A single-target app has grown to hundreds of files and the team wants to split it into feature modules (Swift packages) to speed up builds and enforce ownership. Where do you draw the module boundaries, what shared layer do features depend on, and what technically prevents feature A from importing feature B directly? Explain how one feature exposes something another needs without a direct dependency on it.
Boundaries follow features, with shared foundation and domain modules beneath. Features depend only downward, never sideways — the package graph turns a sideways import into a compile error. When A needs B, it uses a protocol in a shared module that B implements, wired at composition time.
Common mistakes
- ✗Thinking folders or groups give the same isolation as separate modules
- ✗Letting features depend sideways instead of downward on shared modules
- ✗Coupling features to each other's concrete types instead of protocols
Follow-up questions
- →Why does a package graph turn a forbidden import into a compile error?
- →How does a shared protocol let feature A use B without importing it?