iOS Developer (Swift) Interview Questions
Core Overview
Prepare for iOS Developer interviews covering Swift fundamentals, value and reference semantics, protocols, generics, memory management, Swift concurrency, networking, SwiftUI, UIKit, state management, app architecture, persistence, testing, performance, and production iOS engineering.
Ready to test your knowledge?
Launch a focused practice session to review questions without distraction.
What is the difference between let and var in Swift, and how does Swift type inference work?
Direct Answer
let declares a binding that cannot be reassigned, var declares a mutable binding, and Swift can usually infer a variable or constant type from its initialized value.
Detailed Explanation
Swift is statically typed, but developers often do not need to write explicit type annotations because the compiler can infer types from expressions.
let
Use let when a binding should not be reassigned after initialization.
`swift
let candidateID = UUID()
Using immutable bindings by default makes mutation more explicit and can make code easier to reason about.
var
Use var when the binding needs to change.
`swift
var applicationCount = 0
applicationCount += 1
Type inference
Swift can infer types from initialized values.
`swift
let name = "Alex"
let score = 95
The compiler can infer String for name and an integer type for score according to the expression and context.
An explicit annotation can still be useful when it improves the contract or changes the intended inferred type:
`swift
let percentage: Double = 95
Static typing remains in effect
Type inference does not make Swift dynamically typed.
Once the compiler establishes the binding type, incompatible values cannot simply be assigned later.
Binding immutability and value mutability are related but distinct concepts
For value types, a value bound with let cannot have its mutable properties changed through that binding.
For reference types, a let constant prevents changing which object the reference identifies, but the referenced class instance may still expose mutable state.
For example:
`swift
let service = CandidateService()
service.isEnabled = true
can be valid if CandidateService is a class and isEnabled is mutable.
Therefore let should not be described as making an entire reference object deeply immutable.
Prefer clear intent
Use let whenever reassignment is unnecessary and introduce var when actual mutation is part of the algorithm.
Code Example
struct Candidate {
let id: UUID
var name: String
}
let candidateID = UUID()
var candidate = Candidate(
id: candidateID,
name: "Alex"
)
candidate.name = "Sam"
// candidate.id can't be reassigned
// because that property uses let.Common Interview Pitfalls
- Assuming Swift type inference means the language is dynamically typed.
- Using var everywhere even when a binding never changes.
- Assuming let makes every referenced class object deeply immutable.
- Adding explicit type annotations everywhere even when they provide no additional clarity.
- Attempting to mutate a value-type property through a let-bound instance.
- Confusing constant binding semantics with object identity and internal class mutability.
- Assuming inferred types can later change freely.
- Using mutation where a new derived value would make the code clearer.
What is an Optional in Swift, and what are the safest ways to unwrap optional values?
Direct Answer
Optional represents either a wrapped value or no value; use techniques such as if let, guard let, optional chaining, or nil coalescing instead of force-unwrapping uncertain values.
Detailed Explanation
Swift models the possible absence of a value explicitly using Optional.
For example:
`swift
var middleName: String?
means the variable may contain a String or may contain no value.
Conceptually, String? is shorthand for Optional<String>.
Optional binding with if let
`swift
if let middleName {
print(middleName)
}
The body executes only when the optional contains a value, and the unwrapped value is available inside the branch.
guard let
guard is useful when valid execution requires the value to exist:
`swift
guard let candidate else {
return
}
After the guard succeeds, candidate can be used in the remainder of the scope.
This often reduces deeply nested conditional code.
Nil-coalescing operator
Use ?? when a meaningful fallback exists:
`swift
let headline = profile.headline ?? "No headline"
Optional chaining
Optional chaining lets code access members only if the optional contains a value:
`swift
let count = candidate.resume?.skills.count
The result remains optional when the chain may fail because an intermediate value is absent.
Force unwrap
The postfix ! unwraps without checking:
`swift
let value = candidateName!
If the optional is nil, the program encounters a runtime failure.
Use force unwrapping only when the invariant genuinely guarantees a value and that guarantee is clear at the use site.
It should not be the default response to an optional compiler error.
Optionals are part of API design
If a value is genuinely optional in the domain, represent that explicitly.
If a value is required for an object to be valid, prefer constructing the object so the value is required rather than creating an optional property that every caller must unwrap later.
Code Example
struct Candidate {
let id: UUID
let name: String
let headline: String?
}
func displayHeadline(
for candidate: Candidate
) -> String {
candidate.headline
?? "No headline provided"
}
func printHeadline(
for candidate: Candidate?
) {
guard let candidate else {
return
}
if let headline =
candidate.headline {
print(headline)
}
}Common Interview Pitfalls
- Using force unwrap whenever the compiler reports an optional value.
- Assuming an Optional always contains its wrapped type.
- Creating optional properties for values that are actually required domain invariants.
- Using deeply nested if-let statements when guard could express an early failure more clearly.
- Using an arbitrary fallback with nil coalescing when absence has meaningful domain semantics.
- Assuming optional chaining returns a non-optional result regardless of the chain.
- Treating nil as equivalent to an empty string or zero without domain justification.
- Force-unwrapping values returned from external APIs without validating their assumptions.
What is the difference between structs and classes in Swift, and how should you choose between value semantics and reference identity?
Direct Answer
Structs are value types whose values are copied semantically, while classes are reference types with identity and shared-reference behavior; choose based primarily on model semantics.
Detailed Explanation
Swift structures and classes can both contain properties, methods, initializers, and protocol conformances, but their fundamental semantics differ.
Structures are value types
When a structure value is assigned to another variable or passed through value semantics, changes to one logical value do not mutate another independent value.
`swift
struct Profile {
var name: String
}
var first = Profile(name: "Alex")
var second = first
second.name = "Sam"
first.name remains "Alex".
Classes are reference types
`swift
final class Session {
var token: String = ""
}
let first = Session()
let second = first
second.token = "abc"
Both constants refer to the same instance, so mutation is visible through either reference.
Identity
Classes have object identity.
Two class instances can have equivalent property values while still being different instances.
Swift provides identity comparison for class instances using === and !==.
Apple recommends structures by default
For ordinary data models, structures often make mutation and ownership easier to reason about.
Classes are particularly useful when:
Struct does not mean stack and class does not simply mean heap
Value/reference semantics are language concepts. Do not teach the distinction as a guaranteed rule that every struct physically lives on the stack and every class always lives in one particular memory location.
Compiler and runtime optimizations can vary.
Copy-on-write
Standard library value types such as Array, String, and Dictionary can use implementation optimizations such as copy-on-write while preserving value semantics to callers.
Do not confuse implementation optimization with reference semantics.
Choose from semantics first
Ask whether independent values or shared identity best represents the concept before choosing based on assumed performance.
Code Example
struct CandidateProfile {
var name: String
}
var original =
CandidateProfile(
name: "Alex"
)
var edited = original
edited.name = "Sam"
print(original.name)
// Alex
final class Session {
var token: String = ""
}
let firstSession = Session()
let secondSession = firstSession
secondSession.token = "abc"
print(firstSession.token)
// abcCommon Interview Pitfalls
- Assuming every struct must physically be allocated on the stack.
- Assuming every class reference assignment creates a new independent instance.
- Choosing classes for all models merely because other object-oriented languages commonly do so.
- Choosing structs solely because they are assumed to be faster.
- Confusing copy-on-write implementation with reference semantics.
- Using shared class mutation where independent value semantics would simplify reasoning.
- Using value types when a framework genuinely requires object identity.
- Confusing value equality with reference identity.
How do Swift closures capture surrounding values, and why do escaping closures require careful lifetime management?
Direct Answer
Closures can capture values and references from surrounding scope; escaping closures can outlive the function call, so reference captures may affect object lifetime and create retain cycles.
Detailed Explanation
Closures are self-contained blocks of executable behavior that can be stored, passed, and executed later.
They can capture values from their surrounding context.
Capturing surrounding state
`swift
var total = 0
let increment = {
total += 1
}
The closure captures access to total.
Capture semantics depend on the captured declaration and whether it has value or reference semantics.
Escaping closures
A closure is escaping when it can outlive the function invocation that received it.
Common examples include completion handlers stored for later asynchronous invocation.
Escaping closures require explicit API semantics because their lifetime can extend beyond the immediate stack of the function call.
Capturing self
A class instance can retain a closure, and that closure can strongly capture the same instance.
For example:
`text
object
-> closure
-> object
can form a strong reference cycle.
Capture lists
Swift capture lists allow developers to control how references are captured.
A common pattern is:
`swift
{ [weak self] in
self?.handleResult()
}
when the closure should not keep the owning object alive.
weak versus unowned
weak references are optional and become nil when the referenced instance is deallocated.
unowned expresses a stronger lifetime assumption: the referenced object is expected to remain alive whenever the unowned reference is accessed.
Using unowned with an incorrect lifetime assumption can cause a runtime failure.
Do not mechanically write [weak self] in every closure. Decide whether the closure should keep the instance alive.
Value capture
Capture lists can also capture a value at closure creation time:
`swift
let handler = { [currentName = name] in
print(currentName)
}
This can differ from referring to a later-changing surrounding variable.
The important design question is ownership: which object should keep which other object or callback alive, and for how long?
Code Example
final class ProfileLoader {
var onLoaded: (() -> Void)?
func begin() {
onLoaded = { [weak self] in
guard let self else {
return
}
self.refreshUI()
}
}
private func refreshUI() {
// Update state.
}
}Common Interview Pitfalls
- Assuming closures never affect the lifetime of captured class instances.
- Capturing self strongly in a closure retained by self and creating a reference cycle.
- Adding weak self to every closure without considering required ownership semantics.
- Using unowned when the captured instance may actually be deallocated first.
- Assuming captured variables always represent immutable snapshots.
- Forgetting that an escaping closure may execute after the receiving method has returned.
- Using capture lists without understanding whether value or reference semantics are required.
- Fixing a retain cycle by weakening references without considering whether the callback must keep an object alive.
How does Automatic Reference Counting manage class instances in Swift, and how do strong, weak, and unowned references affect object lifetime?
Direct Answer
ARC tracks strong references to class instances and deallocates instances when strong ownership ends; weak and unowned references provide non-owning relationships with different lifetime guarantees.
Detailed Explanation
Swift uses Automatic Reference Counting, or ARC, to manage the lifetime of class instances.
ARC primarily concerns reference-type object ownership rather than providing general garbage collection of arbitrary object graphs.
Strong references
Normal class references are strong unless declared otherwise.
A strong reference keeps the class instance alive.
When no strong ownership remains, the instance becomes eligible for deinitialization according to Swift lifetime semantics.
Strong reference cycles
Consider two class instances that strongly retain one another:
`text
Parent -> Child
Parent <- Child
If no external references remain but those internal references are both strong, the cycle can prevent the instances from being released.
weak
A weak reference does not keep the referenced object alive.
Weak references are appropriate when the relationship should become absent after the owner is deallocated.
A weak reference is therefore optional and can become nil automatically.
A common example is a delegate relationship where the delegating object should not own the delegate.
`swift
weak var delegate: CandidateCoordinatorDelegate?
unowned
An unowned reference is also non-owning but represents a relationship where the referenced object is expected to outlive or have a lifetime at least as long as the holder's access to that reference.
Unlike a weak optional relationship, it does not automatically provide safe optional access after the target is gone.
An incorrect unowned lifetime assumption can produce a runtime failure.
deinit
Classes can define deinit to perform cleanup when an instance is being deinitialized.
Do not rely on deinit timing as a substitute for explicitly managing externally visible lifecycle operations when deterministic behavior is required.
ARC is not a cycle detector
ARC does not automatically break every strong-reference cycle.
Developers must design ownership relationships deliberately.
Memory debugging
When investigating unexpected retention, inspect ownership graphs and use Xcode/Instruments memory tools rather than inserting weak references randomly.
The goal is not to make all references weak. The goal is to correctly represent which object owns which lifetime.
Code Example
protocol CandidateViewControllerDelegate:
AnyObject {
func didFinish()
}
final class CandidateViewController {
weak var delegate:
CandidateViewControllerDelegate?
deinit {
print(
"CandidateViewController deallocated"
)
}
}
final class Coordinator:
CandidateViewControllerDelegate {
var viewController:
CandidateViewController?
func didFinish() {
viewController = nil
}
}Common Interview Pitfalls
- Describing ARC as a tracing garbage collector that automatically breaks reference cycles.
- Making every relationship strong and ignoring ownership direction.
- Making every relationship weak and accidentally allowing required dependencies to disappear.
- Using unowned when the referenced object may have a shorter lifetime.
- Forgetting that weak references must support absence.
- Assuming a strong reference cycle disappears merely because external references are gone.
- Using deinit as the sole correctness mechanism for time-sensitive external operations.
- Attempting to solve retention problems without inspecting the ownership graph.
How would you design production Swift models and ownership relationships using structs, classes, optionals, closures, and ARC without introducing hidden shared state or memory leaks?
Direct Answer
Prefer value semantics for independent data, use classes when identity is meaningful, model absence explicitly, define ownership direction, and weaken references only where the relationship is genuinely non-owning.
Detailed Explanation
Production Swift model design is fundamentally about semantics and ownership rather than choosing whichever language feature is most concise.
1. Begin with value versus identity semantics
For data such as:
structures are often a strong default because independent values make mutation easier to reason about.
Use classes when identity or shared lifecycle genuinely matters, such as:
Do not choose classes automatically simply because the type contains methods.
2. Make mutation intentional
Prefer let for bindings and properties that should not change.
Use var where mutation is truly part of the model.
A model whose every property is mutable often allows states the domain never intended.
3. Represent absence explicitly
Use optionals only for values that can genuinely be absent.
Do not respond to optional complexity by force-unwrapping at every call site.
If a model cannot be valid without a value, require it during initialization or transformation into the trusted model.
4. Separate transport models from trusted models where needed
An API payload may have missing or malformed data.
Conceptually:
`text
network JSON
↓
Decodable transport representation
↓
validation / transformation
↓
trusted domain model
Decodable proves that decoding succeeded according to the decoding contract; it does not automatically prove every business invariant.
5. Define ownership direction
For every class relationship, ask:
Strong references should represent ownership.
Weak or unowned relationships should represent non-ownership with the appropriate lifetime semantics.
6. Design delegate ownership deliberately
A child object often should not strongly own its coordinator or delegate.
A weak class-constrained delegate reference can prevent a cycle where the coordinator already owns the child.
But weak is not a universal delegate rule; ownership follows the actual architecture.
7. Review closure ownership
If an object stores a closure and the closure captures that object strongly, inspect whether a cycle forms.
Use capture lists according to desired lifetime rather than habit.
For a short-lived closure that should keep the object alive until completion, a strong capture may be correct.
For a long-lived callback stored by the owner itself, weak capture may be required.
8. Avoid global mutable service state
A singleton class shared throughout the application can introduce hidden coupling and difficult synchronization requirements.
Use explicit dependency injection and narrowly scoped ownership unless a process-wide service lifetime truly represents the problem.
9. Use identity intentionally
Class object identity and domain identity are not always the same concept.
A remote database entity can often be represented locally as a struct containing a stable identifier even though the real-world entity has identity.
Apple specifically notes this as a case where value semantics can still be appropriate.
10. Avoid speculative memory optimization
Do not replace understandable value models with shared mutable classes solely to avoid assumed copying costs.
Swift and its standard library perform implementation optimizations while preserving language semantics.
Profile actual memory and performance behavior before redesigning ownership.
11. Treat lifecycle as architecture
Navigation coordinators, view models, observers, callbacks, tasks, timers, and resources can all extend object lifetime.
When a screen disappears but its objects remain retained, investigate the full ownership graph rather than assuming ARC is malfunctioning.
12. Keep UI lifecycle separate from domain validity
A view disappearing should not automatically destroy business data that belongs to a longer-lived domain or application scope.
Conversely, domain models should not retain UI controllers merely to send updates.
13. Use protocols to reduce concrete coupling where useful
Value types and classes can both conform to protocols.
Use protocols for meaningful behavioral contracts—not automatically one protocol for every concrete type.
14. Validate architecture with lifecycle tests and Instruments
Production verification should include both correctness and lifecycle behavior.
Check that objects expected to disappear actually deallocate and investigate unexpected retention using Xcode memory tools and Instruments.
A strong Swift design makes ownership visible, keeps value semantics where independence matters, and limits shared mutable reference state to places where identity genuinely requires it.
Code Example
struct Candidate: Identifiable {
let id: UUID
let name: String
let headline: String?
}
protocol CandidateCoordinatorDelegate:
AnyObject {
func candidateFlowDidFinish()
}
final class CandidateCoordinator {
private var child:
CandidateViewController?
func start(
candidate: Candidate
) {
let controller =
CandidateViewController(
candidate: candidate
)
controller.delegate = self
child = controller
}
}
extension CandidateCoordinator:
CandidateCoordinatorDelegate {
func candidateFlowDidFinish() {
child = nil
}
}
final class CandidateViewController {
let candidate: Candidate
weak var delegate:
CandidateCoordinatorDelegate?
init(candidate: Candidate) {
self.candidate = candidate
}
}Common Interview Pitfalls
- Using reference types for every model and creating unnecessary shared mutable state.
- Using structs everywhere even when object identity or framework requirements demand reference semantics.
- Making most model properties optional instead of establishing valid construction invariants.
- Force-unwrapping external or lifecycle-dependent values throughout the app.
- Adding weak references randomly without defining ownership direction.
- Strongly capturing self in a closure retained by the same object without examining the resulting ownership cycle.
- Using global singleton services as the default dependency model.
- Assuming a remote entity must be represented by a local reference type merely because it has identity.
- Redesigning models around theoretical copying costs without profiling.
- Coupling domain objects directly to UI controllers and navigation lifecycle.
What is a protocol in Swift, and how do protocol conformance and protocol extensions support reusable design?
Direct Answer
A Swift protocol defines required capabilities that conforming types provide, while extensions can add shared implementations or additional behavior without requiring class inheritance.
Detailed Explanation
A Swift protocol defines a contract describing capabilities that conforming types must provide.
For example:
`swift
protocol IdentifiableCandidate {
var id: UUID { get }
var name: String { get }
}
A structure, class, or other supported type can adopt that protocol:
`swift
struct Candidate: IdentifiableCandidate {
let id: UUID
let name: String
}
Protocols describe capabilities
A protocol can require members such as:
The concrete type decides how those requirements are implemented.
Protocol conformance
Conformance means the type satisfies the protocol requirements.
This allows APIs to depend on the required capability rather than on one concrete implementation.
Protocol extensions
Extensions can add behavior shared by conforming types.
`swift
extension IdentifiableCandidate {
var displayName: String {
name.uppercased()
}
}
This can reduce duplicated implementation while preserving value-type support and avoiding unnecessary class inheritance.
Protocols are not interfaces in the narrowest class-only sense
Swift protocols can be adopted by structures, enumerations, classes, and other supported types depending on the requirement.
They are therefore useful beyond traditional object-oriented inheritance.
Use protocols where an abstraction is meaningful
Do not create a protocol for every concrete type merely to claim that the code is protocol-oriented.
A protocol is most useful when:
Composition
Swift supports protocol composition when a value must satisfy several protocol requirements.
Design protocols around coherent capabilities rather than creating one enormous protocol containing unrelated behavior.
Protocol-oriented design is about modeling useful behavioral contracts, not maximizing the number of protocols in the codebase.
Code Example
protocol CandidateStore {
func candidate(
id: UUID
) async throws -> Candidate?
}
struct Candidate {
let id: UUID
let name: String
}
final class CandidateService {
private let store: any CandidateStore
init(store: any CandidateStore) {
self.store = store
}
func load(
id: UUID
) async throws -> Candidate? {
try await store.candidate(
id: id
)
}
}Common Interview Pitfalls
- Creating a protocol for every concrete type even when no abstraction boundary exists.
- Using one enormous protocol that combines unrelated responsibilities.
- Assuming only classes can conform to Swift protocols.
- Using inheritance when protocol composition better represents independent capabilities.
- Adding default implementations that accidentally hide important required behavior.
- Treating protocols as a substitute for defining clear ownership and lifecycle semantics.
- Exposing implementation details through protocol requirements unnecessarily.
- Assuming protocol-oriented design means classes should never be used.
What are generics in Swift, and how do generic constraints and associated types preserve type relationships?
Direct Answer
Generics parameterize reusable code while preserving concrete type information; constraints and associated types describe the capabilities and relationships required by that code.
Detailed Explanation
Generics let Swift APIs operate over multiple types while retaining compile-time relationships between those types.
Generic functions
Consider:
`swift
func first<Element>(
_ values: [Element]
) -> Element? {
values.first
}
Element represents the array element type.
If the caller supplies [Candidate], the result is Candidate? rather than an untyped value that requires casting.
Generic types
Types can also be parameterized:
`swift
struct Cache<Key, Value> {
}
This allows one implementation to preserve different key/value relationships.
Constraints
An unconstrained generic parameter exposes only operations valid for any possible type.
If an implementation needs more capabilities, constrain the type.
For example:
`swift
func unique<Value: Hashable>(
_ values: [Value]
) -> Set<Value> {
Set(values)
}
The Hashable constraint establishes the capability required by Set.
where clauses
More complex relationships can be expressed using where clauses.
These can relate generic parameters, associated types, or protocol conformances.
Associated types
A protocol can declare a placeholder type associated with each conforming implementation.
Conceptually:
`swift
protocol Repository {
associatedtype Item
func load() -> [Item]
}
Each conforming repository chooses its corresponding Item type while preserving that relationship throughout the protocol requirements.
Generics versus broad type erasure
A generic API often preserves more static information than storing unrelated values in broad containers.
Use generics when callers and implementations benefit from knowing how types relate.
Do not make every abstraction generic
If only one concrete type is meaningful, a generic parameter can make the API harder to understand without creating useful reuse.
The goal is to encode meaningful type relationships, not to maximize generic syntax.
Code Example
protocol Identified {
associatedtype ID: Hashable
var id: ID { get }
}
func indexByID<Value: Identified>(
_ values: [Value]
) -> [Value.ID: Value] {
Dictionary(
uniqueKeysWithValues:
values.map {
($0.id, $0)
}
)
}Common Interview Pitfalls
- Using Any when a generic relationship should preserve concrete type information.
- Adding generic parameters that do not express a meaningful relationship.
- Using constraints that are broader or more concrete than the algorithm requires.
- Assuming generic code loses compile-time type safety.
- Confusing associated types with stored runtime properties.
- Creating generic abstractions for APIs that only have one meaningful implementation type.
- Ignoring protocol constraints required by operations such as hashing or comparison.
- Adding type erasure prematurely before determining whether generics can express the relationship directly.
What is the difference between some Protocol and any Protocol in modern Swift?
Direct Answer
some Protocol hides one specific conforming concrete type chosen by the implementation, while any Protocol is an existential value that can contain different conforming concrete types.
Detailed Explanation
Modern Swift distinguishes opaque types from existential types explicitly.
Understanding that distinction is important for API design because both forms mention a protocol but preserve different amounts of type information.
Opaque types: some Protocol
An opaque type hides the exact concrete type from the caller while preserving the fact that one specific underlying concrete type exists.
Conceptually:
`swift
func makeStore() -> some CandidateStore {
InMemoryCandidateStore()
}
The implementation chooses the concrete return type.
The caller knows only the protocol capabilities exposed by the API, but the compiler can still reason about a consistent underlying type.
Existential types: any Protocol
An existential can hold a value of any concrete type satisfying the protocol.
`swift
var store: any CandidateStore
At runtime, that value may contain different conforming implementations at different times where the variable and protocol requirements allow it.
Apple describes this as an existential container or box representing a dynamically stored conforming value.
Why the distinction matters
Suppose an API needs heterogeneous values:
`swift
let services: [any Service]
An existential is natural because different concrete service types can coexist in the collection.
If an API simply wants to hide one implementation-specific return type, an opaque result may preserve stronger type relationships.
Generics and some
Generic parameters and opaque parameters/results preserve a concrete underlying type relationship that the compiler can use.
For example:
`swift
func process<S: Service>(
_ service: S
)
preserves S as one concrete conforming type for the invocation.
any provides flexibility at a cost in static specificity
An existential erases some information about the exact concrete type behind the protocol boundary.
That flexibility is useful when heterogeneous storage or runtime substitution is required.
Do not automatically replace every protocol use with any, and do not automatically replace existentials with generics.
Choose according to whether the API needs:
Avoid outdated mental models
Protocols themselves express conformance requirements. any P explicitly represents an existential value whose stored concrete value conforms to P.
The distinction should be part of deliberate modern Swift API design.
Code Example
protocol Renderer {
func render() -> String
}
struct TextRenderer: Renderer {
func render() -> String {
"Text"
}
}
struct ImageRenderer: Renderer {
func render() -> String {
"Image"
}
}
func makeRenderer()
-> some Renderer {
TextRenderer()
}
var runtimeRenderer:
any Renderer =
TextRenderer()
runtimeRenderer =
ImageRenderer()Common Interview Pitfalls
- Treating some Protocol and any Protocol as interchangeable syntax.
- Assuming an opaque return type can arbitrarily return unrelated concrete types from different branches without satisfying its underlying-type requirements.
- Using existentials everywhere even when preserving a concrete generic relationship would improve the API.
- Using generics everywhere when heterogeneous runtime storage is the actual requirement.
- Assuming any Protocol means the runtime value has no concrete type.
- Confusing protocol conformance with existential storage.
- Ignoring associated-type relationships when choosing between generics and existential types.
- Choosing abstraction syntax from style preference instead of API semantics.
How should Swift APIs use throws, do-catch, Result, and domain error types?
Direct Answer
Throwing functions propagate failures through normal control flow, do-catch handles thrown errors, and Result explicitly stores either a success value or a typed failure value.
Detailed Explanation
Swift provides language-level error handling for operations that can fail.
Error protocol
Error types conform to Error.
Enumerations are often a good way to model a finite set of domain failures:
`swift
enum CandidateError: Error {
case notFound
case invalidProfile
case networkUnavailable
}
Associated values can carry additional information needed for diagnosis or recovery.
throws
A throwing function declares that it can fail:
`swift
func loadCandidate() throws -> Candidate
The caller must handle or propagate the thrown error.
try and do-catch
`swift
do {
let candidate =
try loadCandidate()
} catch CandidateError.notFound {
// handle known failure
} catch {
// handle remaining failures
}
This integrates failure directly into Swift control flow.
try?
try? converts the operation into an optional result by discarding the specific thrown error.
This can be appropriate when all failures intentionally mean simple absence.
Do not use it when callers need to distinguish why the operation failed.
try!
try! asserts that an operation will not throw.
If the operation throws, the application fails at runtime.
Use it only when the non-throwing invariant is genuinely guaranteed and obvious.
Do not use try! to silence normal production failures such as networking or parsing problems.
Result
Swift Result<Success, Failure> explicitly represents either:
`swift
.success(value)
or:
`swift
.failure(error)
The failure type conforms to Error.
Result is particularly useful when failure must be stored, passed through callbacks, or represented as data rather than propagated immediately through throwing control flow.
throws versus Result
Prefer throws for normal call-stack error propagation when the operation naturally fails at the point of invocation.
Use Result when the success/failure state needs to be captured as a value.
Avoid wrapping every throwing method in Result merely to make error handling look explicit.
Model recoverable semantics
Domain-specific error cases should tell callers enough to make meaningful decisions without exposing arbitrary implementation details.
For example, distinguish an expected authentication requirement from a corrupted local database when callers need different recovery behavior.
Code Example
enum CandidateError: Error {
case notFound
case invalidResponse
}
func loadCandidate(
id: UUID
) async throws -> Candidate {
guard let candidate =
try await repository
.candidate(id: id)
else {
throw CandidateError
.notFound
}
return candidate
}
func loadAsResult(
id: UUID
) async
-> Result<
Candidate,
CandidateError
> {
do {
return .success(
try await loadCandidate(
id: id
)
)
} catch let error
as CandidateError {
return .failure(error)
} catch {
return .failure(
.invalidResponse
)
}
}Common Interview Pitfalls
- Using try? when the caller needs the actual failure reason.
- Using try! for networking, decoding, or other genuinely fallible operations.
- Catching every error and silently replacing it with an unrelated generic value.
- Using one enormous error enumeration for unrelated subsystems.
- Exposing infrastructure implementation errors directly as every domain API contract.
- Wrapping every throwing API in Result without a reason to store failure as data.
- Returning nil for every failure and losing useful recovery information.
- Logging an error and then swallowing it when the caller still needs to know the operation failed.
How should Codable and JSONDecoder be used without coupling an iOS domain model directly to an external API schema?
Direct Answer
Use Decodable/Codable for serialization contracts, handle decoding failures explicitly, and transform transport representations into domain models when their semantics differ.
Detailed Explanation
Codable combines Swift's Encodable and Decodable protocols for types that participate in serialization.
For HTTP APIs, Decodable and JSONDecoder are commonly used to parse JSON responses.
Decoding
`swift
let response = try decoder.decode(
CandidateResponse.self,
from: data
)
JSONDecoder.decode returns the requested type when decoding succeeds and throws when the supplied data cannot satisfy the decoding contract.
Decoding errors
Failures may occur because of problems such as:
Do not force-try external JSON decoding in production code.
CodingKeys
A model can explicitly map between external field names and Swift properties.
`swift
enum CodingKeys: String, CodingKey {
case firstName = "first_name"
}
Decoder strategies can also handle selected conventions such as snake-case conversion where that behavior correctly matches the API contract.
Transport model versus domain model
Suppose an API returns:
`json
{
"id": "123",
"display_name": "",
"created_at": "2026-08-05T12:00:00Z"
}
A transport model may reflect the wire contract closely:
`swift
struct CandidateResponse: Decodable {
let id: String
let displayName: String
let createdAt: String
}
The application domain may prefer:
`swift
struct Candidate {
let id: CandidateID
let name: NonEmptyName
let createdAt: Date
}
The conversion boundary can validate business expectations and transform representation.
Decoding is not complete domain validation
Successful decoding proves the data matched the decoding logic sufficiently to produce the decoded value.
It does not automatically prove business invariants such as:
Avoid API-schema leakage
If every UI and business component uses the exact server DTO directly, a backend schema change can ripple throughout the app.
Separate models where that separation protects meaningful semantics, not merely to create additional files.
Encoding
The same principle applies to outbound API requests.
Create request DTOs matching what the endpoint expects rather than encoding large internal domain objects automatically.
Serialization is a boundary concern; the domain should remain optimized for application correctness and comprehension.
Code Example
struct CandidateResponse:
Decodable {
let id: UUID
let displayName: String
let createdAt: Date
enum CodingKeys:
String,
CodingKey {
case id
case displayName =
"display_name"
case createdAt =
"created_at"
}
}
struct Candidate {
let id: UUID
let name: String
let createdAt: Date
}
extension Candidate {
init(
response:
CandidateResponse
) throws {
let name =
response.displayName
.trimmingCharacters(
in:
.whitespacesAndNewlines
)
guard !name.isEmpty else {
throw CandidateError
.invalidName
}
self.id = response.id
self.name = name
self.createdAt =
response.createdAt
}
}Common Interview Pitfalls
- Using try! to decode untrusted network JSON.
- Assuming successful Decodable parsing proves every business invariant.
- Making every internal domain model mirror the backend JSON schema exactly.
- Using one model for unrelated request, response, persistence, and UI-state semantics.
- Making every decoding property optional simply to prevent decoding from failing.
- Ignoring useful DecodingError information during diagnostics.
- Exposing raw transport strings throughout the domain when they require semantic conversion.
- Creating unnecessary DTO-to-domain layers when both models genuinely have identical semantics and no boundary benefit.
How would you design a reusable Swift API using protocols, generics, opaque or existential types, errors, and serialization boundaries without overengineering it?
Direct Answer
Expose the smallest meaningful contract, preserve concrete type relationships when useful, use existential storage only when needed, model failures deliberately, and isolate transport schemas at boundaries.
Detailed Explanation
A mature Swift API should make correct usage straightforward while keeping its abstraction cost proportional to the problem being solved.
1. Begin with the concrete problem
Do not begin architecture by creating protocols, generic parameters, and type-erased wrappers before understanding what actually varies.
Start from the required behavior and introduce abstraction where multiple implementations, substitution, or type relationships create real value.
2. Keep protocols cohesive
Prefer focused capabilities such as:
`swift
protocol CandidateLoader {
func load(
id: UUID
) async throws -> Candidate
}
over one enormous service protocol containing unrelated networking, persistence, analytics, caching, and UI responsibilities.
Small contracts reduce coupling and are easier to test and evolve.
3. Preserve type relationships with generics
When two values or operations have an important compile-time relationship, use generics and constraints rather than erasing the relationship prematurely.
For example, a parser whose output type depends on its implementation may express that relationship with an associated type or generic parameter.
4. Use opaque types when the implementation chooses one hidden concrete type
An API returning some Protocol can hide implementation detail while allowing the compiler to preserve one underlying concrete type.
This is useful when the caller should depend on capabilities but should not choose or store arbitrary implementations.
5. Use existential types for runtime heterogeneity
Use any Protocol when the application genuinely needs to store or pass values whose concrete conforming type varies dynamically.
Examples can include collections of heterogeneous plugin-like implementations or dependency properties selected at runtime.
Do not pay the conceptual cost of type erasure when one preserved generic type is sufficient.
6. Do not expose every implementation detail through protocols
A protocol should describe what consumers need to know, not every helper function used internally by one implementation.
Public abstractions are dependencies that must evolve carefully.
7. Model failures at the correct boundary
Infrastructure may produce low-level errors such as decoding failures, URL errors, storage errors, or framework-specific failures.
The application API may expose a smaller failure vocabulary if callers only need distinctions such as:
Do not erase useful diagnostic information blindly; retain underlying errors internally when observability requires them.
8. Choose throws versus Result intentionally
Use throws for ordinary failure propagation through call control flow.
Use Result when success or failure needs to become a stored value, callback payload, queue item, or state representation.
Do not duplicate both models at every API boundary without need.
9. Isolate serialization contracts
Network request/response objects should reflect the external protocol.
Domain objects should express application invariants.
When their semantics differ, transform explicitly:
`text
JSON
↓
Decodable DTO
↓
validation/transformation
↓
domain model
This makes server-format changes less likely to contaminate unrelated application code.
10. Avoid optional-everything DTO design
Making every field optional may make decoding succeed more often while pushing uncertainty into every downstream feature.
Represent optionality according to the actual protocol, then validate what the domain requires.
11. Make test substitution easy without designing only for mocks
Protocols can provide useful seams around networking, clocks, persistent stores, and other side effects.
Do not create abstractions solely so every internal helper can be mocked.
Value-type collaborators and pure functions often need no protocol at all.
12. Keep existential storage at architectural edges where useful
A dependency container or coordinator may legitimately store any CandidateLoader because implementations vary at runtime.
Inside performance-sensitive or heavily generic algorithms, preserving concrete generic information may produce clearer relationships.
Choose based on semantics before performance speculation.
13. Avoid abstraction towers
This kind of chain:
`text
protocol
→ generic wrapper
→ type eraser
→ adapter
→ facade
→ service
can become harder to understand than the original dependency.
Every layer should solve a specific problem.
14. Design APIs for evolution
Changing protocol requirements can affect every conforming implementation.
Adding generic constraints can restrict callers.
Changing public existential or opaque behavior can alter how consumers compose the API.
Treat public Swift type signatures as real compatibility contracts.
15. Keep debugging possible
When translating low-level failures into domain errors, preserve enough underlying context for logs and diagnostics while exposing only appropriate information to user-facing layers.
16. Prefer understandable compiler diagnostics
An abstraction that produces extremely complicated generic errors for ordinary misuse may need simplification even if it is theoretically elegant.
THE API should make the intended path obvious to the engineers maintaining it.
17. Measure before optimizing abstraction representation
Existentials and generic specialization can have different runtime characteristics, but architectural decisions should not be based purely on folklore.
Use Instruments and real workloads when performance matters.
18. Optimize for meaning
A strong Swift API communicates:
The best abstraction is usually the smallest one that accurately preserves those semantics.
Code Example
protocol CandidateTransport {
associatedtype Response:
Decodable
func fetch(
id: UUID
) async throws -> Response
}
protocol CandidateLoading {
func candidate(
id: UUID
) async throws -> Candidate
}
struct CandidateDTO:
Decodable {
let id: UUID
let name: String
}
struct Candidate {
let id: UUID
let name: String
}
final class APIClient:
CandidateLoading {
func candidate(
id: UUID
) async throws -> Candidate {
let dto:
CandidateDTO =
try await fetchDTO(
id: id
)
guard
!dto.name
.trimmingCharacters(
in: .whitespaces
)
.isEmpty
else {
throw CandidateError
.invalidName
}
return Candidate(
id: dto.id,
name: dto.name
)
}
}Common Interview Pitfalls
- Creating protocol abstractions before identifying what behavior actually varies.
- Using existential types everywhere and discarding useful concrete type relationships.
- Using generics everywhere even when runtime heterogeneous storage is the actual requirement.
- Creating type-erasure wrappers where an opaque or generic API would already express the contract.
- Exposing every implementation helper as a public protocol requirement.
- Passing low-level networking and decoding errors throughout every application layer without deliberate error modeling.
- Wrapping every throwing API inside Result without needing failure as stored data.
- Making every decoded field optional merely to avoid decoding errors.
- Coupling the complete domain model directly to a vendor API response schema.
- Building deeply layered abstraction towers whose complexity exceeds the flexibility they provide.
How do async and await work in Swift, and what does it mean for an asynchronous function to suspend?
Direct Answer
An async function can suspend while waiting for asynchronous work, and await marks a potential suspension point without implying that the current thread blocks until completion.
Detailed Explanation
Swift uses async and await to express asynchronous control flow directly in the language.
async functions
A function marked async may suspend during its execution:
`swift
func loadCandidate() async throws -> Candidate
The caller normally invokes it with await:
`swift
let candidate =
try await loadCandidate()
What suspension means
When an asynchronous operation cannot continue immediately, the task can suspend rather than synchronously blocking the thread until the operation completes.
Later, the task can resume and continue execution.
A suspension point is therefore not equivalent to:
`text
block this thread until finished
await marks potential suspension
The await keyword highlights a point where the current asynchronous task may suspend.
The operation does not necessarily suspend every time. If the awaited operation can complete immediately, execution may continue without a meaningful suspension.
async does not mean background thread
Marking a function async does not guarantee that its body executes on a background thread.
Swift concurrency schedules work according to isolation and runtime execution rules.
Actor-isolated code, including main-actor code, can still contain asynchronous functions.
Sequential awaits
This code is sequential:
`swift
let profile =
try await loadProfile()
let jobs =
try await loadJobs()
The second call starts only after the first awaited operation returns according to this control flow.
If operations are independent and should overlap, structured concurrency mechanisms such as async let or task groups may be more appropriate.
Errors and async
An async function can also throw:
`swift
async throws
The caller then uses both try and await.
Keep APIs asynchronous when the operation is naturally asynchronous
Avoid wrapping asynchronous APIs in blocking mechanisms simply to expose a synchronous facade.
A clean asynchronous call chain allows cancellation, error propagation, and task structure to remain visible.
Code Example
struct CandidateService {
let client: APIClient
func candidate(
id: UUID
) async throws -> Candidate {
let response =
try await client.loadCandidate(
id: id
)
return response
}
}
Task {
do {
let candidate =
try await service.candidate(
id: candidateID
)
print(candidate.name)
} catch {
print(error)
}
}Common Interview Pitfalls
- Assuming every async function automatically runs on a background thread.
- Treating await as though it synchronously blocks the current thread.
- Adding async to functions that perform no asynchronous work without a design reason.
- Forgetting that sequential await expressions still execute sequentially.
- Wrapping naturally asynchronous APIs in blocking synchronization unnecessarily.
- Ignoring errors from async throwing functions.
- Assuming every await point always causes an actual suspension.
- Using detached or unstructured tasks merely to avoid making an API async.
What is a Task in Swift, and how does cooperative task cancellation work?
Direct Answer
A Task represents asynchronous work; cancellation marks the task as canceled and propagates through structured child work, but running code must cooperate with cancellation appropriately.
Detailed Explanation
A Swift Task represents a unit of asynchronous work.
Tasks provide the runtime context in which asynchronous functions execute.
Creating a task
For example:
`swift
let task = Task {
try await loadProfile()
}
The task begins asynchronous work and produces a handle that can be awaited or canceled.
Cancellation is cooperative
Calling:
`swift
task.cancel()
marks the task as canceled.
It does not forcibly terminate the executing code at an arbitrary instruction.
The work must respond to cancellation appropriately.
Checking cancellation
Code can inspect:
`swift
Task.isCancelled
or use:
`swift
try Task.checkCancellation()
which throws when cancellation has been requested.
This is useful in CPU loops or between stages of longer workflows.
Many async APIs cooperate automatically
System asynchronous APIs may observe task cancellation themselves.
For example, Swift concurrency integrates cancellation with URLSession asynchronous operations.
The application should still define what cancellation means for its own multi-step workflow.
Structured cancellation
When a parent structured task is canceled, its child tasks and task groups receive cancellation as well.
That relationship is one benefit of structured concurrency.
Cancellation is not rollback
Suppose a task has already:
1. Uploaded data
2. Changed local persistent state
3. Sent a server request
Canceling the task does not automatically undo those completed effects.
Business compensation and transaction semantics are separate concerns.
Retain the handle when ownership needs cancellation
A screen or controller that starts a long-running task may retain its task handle so it can cancel work when that work is no longer needed.
However, lifecycle design should still avoid arbitrary task creation without clear ownership.
The important principle is:
`text
cancellation request
→ cooperative observation
→ appropriate termination/cleanup
not forced execution termination.
Code Example
final class SearchController {
private var searchTask:
Task<Void, Never>?
func search(
query: String
) {
searchTask?.cancel()
searchTask = Task {
do {
try await Task.sleep(
for:
.milliseconds(300)
)
try Task
.checkCancellation()
let results =
try await loadResults(
query: query
)
try Task
.checkCancellation()
await display(results)
} catch is CancellationError {
// Expected cancellation.
} catch {
await display(error)
}
}
}
deinit {
searchTask?.cancel()
}
}Common Interview Pitfalls
- Assuming Task.cancel immediately terminates arbitrary executing code.
- Starting long-running tasks without defining who owns or cancels them.
- Treating cancellation as automatic rollback of already completed side effects.
- Ignoring cancellation in expensive CPU loops.
- Catching CancellationError and logging every expected cancellation as an application failure.
- Creating unrelated unstructured tasks when structured child work would express lifetime better.
- Assuming cancellation cannot propagate to child tasks.
- Discarding task handles even when lifecycle-driven cancellation is required.
How do async let and task groups provide structured concurrency in Swift?
Direct Answer
async let creates a known child task, while task groups manage dynamic child tasks; structured children remain bounded by the parent task lifetime and participate in cancellation and priority relationships.
Detailed Explanation
Structured concurrency organizes concurrent work into parent-child task relationships whose lifetimes are lexically bounded.
This makes concurrent code easier to reason about than arbitrary independent background tasks.
async let
Use async let when the number of independent child operations is known statically.
`swift
async let profile = loadProfile()
async let jobs = loadJobs()
let result =
try await (
profile,
jobs
)
Both child operations can make progress concurrently before their results are awaited.
This differs from sequentially awaiting each function call before starting the next.
Task groups
Use a task group when the number of child operations is dynamic.
For example:
`swift
await withTaskGroup(
of: Image.self
) { group in
for url in urls {
group.addTask {
await loadImage(url)
}
}
for await image in group {
consume(image)
}
}
The group allows child tasks to be added dynamically and their results consumed as they complete.
Structured lifetime
Child tasks do not escape the lifetime of the structured scope.
The task-group operation does not return until all child tasks have completed.
This provides a strong lifetime guarantee that arbitrary independently created tasks do not inherently provide.
Cancellation
Cancellation propagates through structured task relationships.
However, cancellation remains cooperative, so children must reach cancellation-aware operations or check cancellation as appropriate.
Error handling
Throwing task groups allow child failures to participate in normal Swift error propagation.
The application still needs to decide whether one failed child should invalidate all results or whether partial results are meaningful.
Avoid unlimited fan-out
A task group makes it easy to create many child tasks, but that does not mean an application should start an unlimited number of expensive operations.
Network services, memory, CPU, and backend APIs have finite capacity.
Large workloads may need batching, explicit limits, queues, or another resource-control strategy.
Structured does not mean sequential
Structured concurrency gives lifetime and cancellation relationships; the child operations may still execute concurrently.
Code Example
func loadCandidates(
ids: [UUID]
) async throws -> [Candidate] {
try await withThrowingTaskGroup(
of: Candidate.self
) { group in
for id in ids {
group.addTask {
try await loadCandidate(
id: id
)
}
}
var candidates: [Candidate] = []
for try await candidate
in group {
candidates.append(
candidate
)
}
return candidates
}
}Common Interview Pitfalls
- Using sequential awaits when independent operations were intended to overlap.
- Creating unstructured Task instances for work that naturally belongs to a parent scope.
- Assuming child tasks can safely outlive the structured task-group scope.
- Starting an unlimited number of expensive child tasks without considering resource capacity.
- Assuming structured concurrency means child tasks execute sequentially.
- Ignoring cancellation merely because the task is inside a structured group.
- Assuming task-group result order automatically matches input order.
- Ignoring partial-failure semantics when several child operations may fail independently.
How do actors, MainActor, isolation, and Sendable help Swift prevent data races?
Direct Answer
Actors isolate mutable state, MainActor provides global isolation for main-thread-oriented work, and Sendable describes values that can safely cross concurrency isolation boundaries.
Detailed Explanation
Swift concurrency is built around data isolation rather than expecting developers to protect all shared mutable state manually.
Actors
An actor is a reference type whose mutable state is protected by actor isolation.
For example:
`swift
actor CandidateCache {
private var values:
[UUID: Candidate] = [:]
func candidate(
id: UUID
) -> Candidate? {
values[id]
}
func store(
_ candidate: Candidate
) {
values[candidate.id] =
candidate
}
}
Code outside the actor normally accesses actor-isolated state asynchronously because that work must respect the actor's isolation boundary.
Actor isolation protects access, not every business invariant automatically
An actor prevents unsafe concurrent access to its isolated state, but logical operations can still contain suspension points.
If an actor method reads state, performs await, and then modifies state, other actor work may run during that suspension.
Therefore assumptions made before an await may need to be validated again afterward.
This is sometimes described as actor reentrancy.
MainActor
MainActor is a global actor whose executor corresponds to the main dispatch queue.
Code isolated to @MainActor can be used for state and operations that must remain associated with the main actor, including UI-oriented work.
Do not scatter manual DispatchQueue.main.async calls throughout code when actor isolation can express the requirement structurally.
Sendable
Sendable describes values that can safely be transferred across concurrency domains without introducing data races.
Many value-semantic standard-library types are naturally suitable for this kind of transfer.
Mutable classes require more careful reasoning.
A class should not be declared Sendable merely to silence compiler errors if its mutable state can actually be accessed concurrently.
Compiler enforcement
Swift 6 language mode strengthens compile-time data-race checking around actor isolation and Sendable relationships.
The purpose is to prevent unsafe sharing rather than to make every type globally shareable.
Reduce sharing first
Often the cleanest concurrency design is to avoid sharing mutable objects between concurrent tasks at all.
Use immutable/value-semantic data when possible, actors for shared mutable ownership, and Sendable only where data really crosses isolation boundaries.
Code Example
struct Candidate: Sendable {
let id: UUID
let name: String
}
actor CandidateCache {
private var storage:
[UUID: Candidate] = [:]
func store(
_ candidate: Candidate
) {
storage[candidate.id] =
candidate
}
func candidate(
id: UUID
) -> Candidate? {
storage[id]
}
}
@MainActor
final class CandidateViewModel {
private(set) var candidate:
Candidate?
func update(
_ candidate: Candidate
) {
self.candidate =
candidate
}
}Common Interview Pitfalls
- Using actors but still assuming state cannot change across an await inside an actor method.
- Marking mutable classes Sendable merely to suppress concurrency diagnostics.
- Assuming an actor means its methods cannot contain suspension points.
- Using MainActor as a performance optimization rather than an isolation contract.
- Sharing mutable reference objects across tasks when value copies or isolated ownership would be simpler.
- Manually dispatching to the main queue everywhere instead of expressing suitable actor isolation.
- Assuming Sendable means immutable in every possible implementation.
- Treating actor isolation as automatic business-transaction atomicity across suspension points.
How should an iOS app use URLSession with async/await to perform HTTP requests, validate responses, decode data, and support cancellation?
Direct Answer
Use URLSession async APIs, inspect the URLResponse and HTTP status, decode successful payloads deliberately, propagate errors, and let task cancellation flow through supported networking operations.
Detailed Explanation
URLSession is the primary Foundation API for HTTP and URL-based data transfer on Apple platforms.
Modern APIs integrate directly with Swift concurrency.
Asynchronous request
For a URL request:
`swift
let (data, response) =
try await session.data(
for: request
)
returns the response body data and a URLResponse asynchronously.
Transport success is not application success
Receiving Data without a networking error does not mean the HTTP operation represents success.
Inspect the response:
`swift
guard let httpResponse =
response as? HTTPURLResponse
else {
throw APIError.invalidResponse
}
An application status must validate HTTP 2xx response codes before attempting to decode content.
For example, a 404 may need to become a domain notFound result rather than being decoded as a successful candidate.
Decode only appropriate responses
Once the status and content expectations are validated:
`swift
return try decoder.decode(
CandidateDTO.self,
from: data
)
Decoding errors should remain distinguishable enough for diagnosis.
Do not expose URLSession everywhere
A networking layer can centralize transport concerns such as:
Feature/domain code can then depend on a smaller semantic API.
Cancellation
URLSession async methods integrate with Swift task cancellation.
If the task performing the request is canceled, the networking operation can participate in cancellation.
This makes it valuable to preserve the surrounding task structure instead of hiding async networking behind unowned callbacks.
Timeouts and retries
Timeout and retry behavior should be designed according to API semantics.
Do not blindly retry every failed request, especially non-idempotent operations that can produce duplicate effects.
UI isolation
Network work does not need to be performed by blocking the main actor.
When decoded data is ready, update main-actor-isolated UI state through its defined isolation boundary.
Separate layers
A healthy flow can look like:
`text
URLSession
↓
HTTP validation
↓
DTO decoding
↓
domain transformation
↓
@MainActor UI state
Each stage has a distinct responsibility.
Code Example
enum APIError: Error {
case invalidResponse
case statusCode(Int)
}
struct APIClient {
let session: URLSession
let decoder: JSONDecoder
func candidate(
request: URLRequest
) async throws
-> CandidateDTO {
let (data, response) =
try await session.data(
for: request
)
guard let httpResponse =
response
as? HTTPURLResponse
else {
throw APIError
.invalidResponse
}
guard
(200..<300)
.contains(
httpResponse.statusCode
)
else {
throw APIError
.statusCode(
httpResponse.statusCode
)
}
return try decoder.decode(
CandidateDTO.self,
from: data
)
}
}Common Interview Pitfalls
- Assuming a URLSession request is successful solely because no transport error was thrown.
- Ignoring HTTP status codes before decoding response bodies.
- Using try! to decode network responses.
- Exposing URLSession and raw HTTP details throughout feature and UI code.
- Updating main-actor UI state through arbitrary shared mutable objects.
- Retrying every failed HTTP request without considering idempotency.
- Discarding task structure and losing useful cancellation propagation.
- Treating successful DTO decoding as automatic proof of all domain invariants.
How would you design production Swift concurrency and networking architecture that keeps UI responsive, prevents data races, supports cancellation, and controls resource usage?
Direct Answer
Use structured concurrency for owned child work, actors for shared mutable state, Sendable-safe boundaries, MainActor for UI state, cancellable networking, and explicit resource limits based on measured workloads.
Detailed Explanation
Production Swift concurrency architecture should make task lifetime, data ownership, isolation, and cancellation visible.
The objective is not to create as many tasks as possible. It is to perform necessary work responsively while keeping state and resources safe.
1. Start asynchronous work from an owned scope
Every task should have a reason to exist and an owner responsible for its lifetime.
Prefer structured concurrency when child work belongs to an operation.
For example, loading profile information and recommendations for one screen may use async let if both operations are known and independent.
Use a task group when the child-work set is dynamic.
2. Avoid unstructured tasks by default
Task { ... } has valid uses, particularly when bridging synchronous lifecycle entry points into asynchronous work.
But repeatedly creating tasks deep inside service methods can hide cancellation, failure, and lifetime relationships.
If a method can simply become async, prefer preserving the caller's task.
Use detached tasks only when detached execution semantics are genuinely required and understood.
3. Propagate cancellation
When a view disappears, search changes, or an operation becomes irrelevant, cancel the owning task when appropriate.
Downstream async operations should remain in that task structure so cancellation can propagate naturally.
For custom CPU work, check cancellation explicitly at reasonable points.
Do not confuse cancellation with compensating already completed external side effects.
4. Isolate mutable shared state
Shared caches, mutable stores, and coordination state should have clear isolation.
An actor may own state that multiple concurrent operations need to access.
Avoid globally shared mutable classes marked @unchecked Sendable simply to silence compiler diagnostics.
@unchecked Sendable transfers the safety responsibility to the programmer and therefore requires a real externally enforced synchronization invariant.
5. Treat Sendable diagnostics as design feedback
When the compiler reports that a non-Sendable value is crossing an isolation boundary, first ask whether that value should be shared at all.
Possible solutions include:
Do not immediately suppress the diagnostic.
6. Keep UI state appropriately isolated
UI-facing mutable state commonly belongs on the main actor.
For example:
`swift
@MainActor
final class SearchViewModel {
private(set) var results: [Job] = []
}
Network waiting and heavy CPU processing should not block UI responsiveness merely because the initiating object is main-actor isolated.
Structure the asynchronous workflow so long-running non-UI computation can execute according to appropriate isolation.
7. Understand actor reentrancy
Actor isolation protects memory access, but an actor method can suspend at await.
Other work may execute on that actor before the original method resumes.
Therefore this pattern requires care:
`text
check state
await remote call
mutate assuming state is unchanged
Revalidate state after suspension when the invariant depends on it.
8. Keep network transport responsibilities centralized
A network client can own:
Domain repositories or services can convert those results into feature-specific semantics.
Do not make every view construct URLRequests independently.
9. Bound concurrent remote work
A list containing thousands of items does not imply that thousands of requests should be started simultaneously.
Consider:
Use batching, bounded worker models, sequential work, or controlled task-group fan-out according to the workload.
10. Design retries around semantics
Retry only failures that may be transient and only when repeated execution is safe.
A duplicate GET may be acceptable according to its semantics; a duplicate state-changing request may require an idempotency mechanism from the backend contract.
Do not build mobile retry logic that amplifies a server outage.
11. Separate caching from networking
A cache actor or repository can coordinate cached values independently of raw URLSession mechanics.
Define freshness, invalidation, offline behavior, and failure fallback explicitly.
Do not call any stored response a cache without a consistency policy.
12. Avoid duplicate in-flight requests when useful
For expensive resources, a state owner can track existing in-flight work and allow several consumers to await the same operation rather than issuing identical network requests.
That optimization adds lifecycle complexity and should be used where the workload justifies it.
13. Keep decoding away from UI assumptions
Decode transport DTOs, validate HTTP semantics, then transform into domain values before updating the view state.
This keeps server schema instability from leaking through every SwiftUI or UIKit screen.
14. Handle partial failure intentionally
When task groups load multiple independent items, define whether:
Do not let the task primitive accidentally define product semantics.
15. Observe concurrency performance
Use Instruments and concurrency diagnostics when responsiveness or throughput becomes a problem.
Measure:
Apple provides Swift Concurrency instrumentation and profiling workflows specifically for this kind of diagnosis.
16. Migrate concurrency deliberately
Swift 6 data-race safety can expose architectural assumptions that older code did not express statically.
Resolve those diagnostics through ownership and isolation improvements rather than scattering unsafe annotations.
Migration can proceed module by module where necessary.
17. Preserve framework lifecycle semantics
UIKit and SwiftUI already have lifecycle and cancellation concepts.
Connect task lifetime to the UI feature lifecycle when the work exists only for that UI.
Move durable work into a longer-lived owner when it must continue beyond the screen.
18. Optimize for comprehensible ownership
A senior Swift concurrency design should answer:
If those answers are unclear, adding more tasks or actors usually increases rather than reduces risk.
Code Example
struct Candidate: Sendable {
let id: UUID
let name: String
}
actor CandidateRepository {
private let api: APIClient
private var cache:
[UUID: Candidate] = [:]
init(api: APIClient) {
self.api = api
}
func candidate(
id: UUID
) async throws -> Candidate {
if let cached =
cache[id] {
return cached
}
try Task
.checkCancellation()
let candidate =
try await api.candidate(
id: id
)
try Task
.checkCancellation()
cache[id] =
candidate
return candidate
}
}
@MainActor
final class CandidateViewModel {
private let repository:
CandidateRepository
private var loadTask:
Task<Void, Never>?
private(set) var candidate:
Candidate?
init(
repository:
CandidateRepository
) {
self.repository =
repository
}
func load(
id: UUID
) {
loadTask?.cancel()
loadTask = Task {
do {
candidate =
try await repository
.candidate(
id: id
)
} catch is CancellationError {
return
} catch {
// Map into UI error state.
}
}
}
}Common Interview Pitfalls
- Creating unstructured tasks throughout the service layer and losing parent-child cancellation relationships.
- Marking unsafe mutable reference types @unchecked Sendable merely to remove compiler diagnostics.
- Putting all networking and CPU processing on MainActor because the workflow began in a view model.
- Assuming actor isolation makes a multi-step operation atomic across await suspension points.
- Launching one network task per item for arbitrarily large collections without resource limits.
- Retrying state-changing requests without understanding backend idempotency.
- Allowing views to construct raw URLSession requests throughout the UI layer.
- Treating task cancellation as automatic compensation for already completed server writes.
- Ignoring Swift concurrency diagnostics during migration instead of fixing ownership boundaries.
- Optimizing concurrency based on intuition without profiling responsiveness and task behavior.
What does it mean that SwiftUI is declarative, and how should developers think about View values and body recomputation?
Direct Answer
SwiftUI views describe UI from current state instead of imperatively mutating widgets; when relevant data changes, SwiftUI reevaluates affected view descriptions and updates the rendered interface.
Detailed Explanation
SwiftUI uses a declarative approach to user interface development.
Instead of creating a UI object and then manually mutating individual properties whenever application state changes, a SwiftUI view describes what the interface should look like for the current state.
For example:
`swift
struct CandidateView: View {
let candidate: Candidate
var body: some View {
VStack {
Text(candidate.name)
if let headline =
candidate.headline {
Text(headline)
}
}
}
}
The body property describes the interface corresponding to the current input values.
Views are descriptions
SwiftUI View conforming types are commonly value types such as structures.
Developers should not treat the view structure itself as the long-lived mutable UIKit-style view object whose fields must be manually synchronized with every visual change.
State drives presentation
Conceptually:
`text
state
↓
view description
↓
SwiftUI reconciliation/rendering
When data on which a view depends changes through supported state or observation mechanisms, SwiftUI can reevaluate the relevant view hierarchy and update what appears on screen.
body may be evaluated repeatedly
Code inside body should therefore describe UI rather than perform arbitrary side effects such as starting network calls, writing files, or mutating global application state simply because the view is evaluated.
SwiftUI determines when view evaluation is needed; application logic should not depend on an assumed exact number of body evaluations.
Identity matters
Although views are value descriptions, SwiftUI maintains state and rendered UI according to view identity and hierarchy relationships.
Changing identity can cause state associated with an earlier view identity to be replaced.
This is particularly relevant for lists, conditional hierarchies, and explicit identifiers.
Keep expensive work out of body
Do not perform expensive parsing, networking, or large repeated computations directly in body if that work can instead be prepared by a model or another appropriate layer.
A good mental model is that body should be inexpensive, deterministic with respect to its inputs, and focused on describing presentation.
Code Example
struct CandidateRow: View {
let candidate: Candidate
var body: some View {
HStack {
VStack(
alignment: .leading
) {
Text(
candidate.name
)
if let headline =
candidate.headline {
Text(headline)
.font(
.caption
)
}
}
Spacer()
if candidate.isFavorite {
Image(
systemName:
"star.fill"
)
}
}
}
}Common Interview Pitfalls
- Treating a SwiftUI View structure as a long-lived mutable UIKit view object.
- Starting network requests directly from body merely because the view is rendered.
- Depending on body being evaluated exactly once.
- Performing expensive transformations repeatedly inside body without measuring or structuring the work appropriately.
- Mutating global state as a side effect of view construction.
- Assuming every state change causes the complete application UI to be recreated from scratch.
- Changing view identity accidentally and then wondering why local state resets.
- Trying to imperatively synchronize every visual property instead of letting state drive presentation.
How do @State and @Binding work in SwiftUI, and what does single source of truth mean?
Direct Answer
@State lets a view own local mutable UI state, while @Binding gives another view read-write access to state owned elsewhere without creating a second source of truth.
Detailed Explanation
SwiftUI data flow works best when each piece of mutable state has a clear owner.
Apple describes this as maintaining a single source of truth for application data.
@State
Use @State when a SwiftUI view owns transient mutable state that belongs to that view identity.
For example:
`swift
@State private var isExpanded = false
The view owns that state and SwiftUI preserves it according to the view's identity.
Typical local state includes:
Do not treat @State like an ordinary stored-property initializer that should be replaced from a parent every render
The wrapped state belongs to SwiftUI-managed storage associated with the view identity.
Use ordinary input properties when the parent simply provides a value.
@Binding
A binding provides read-write access to state owned somewhere else.
For example:
`swift
struct NameEditor: View {
@Binding var name: String
}
The child does not create an independent copy of the source of truth.
The parent can provide:
`swift
NameEditor(
name: $name
)
Single source of truth
If both parent and child maintain independent copies of the same logical state, they can drift apart.
Instead:
`text
Parent owns state
↓
Binding
↓
Child edits same state
Bindings are not general application architecture
Do not thread bindings through many unrelated layers merely to avoid defining a proper model or feature boundary.
Bindings work particularly well for direct UI editing relationships.
State should live at the lowest common owner that needs to coordinate it
If two sibling views must observe or modify one concept, their nearest meaningful parent or an observable model can own that state.
Do not automatically move every state value into a global singleton.
The goal is clear ownership rather than universally global state.
Code Example
struct CandidateEditor: View {
@State private var name =
""
var body: some View {
NameField(
name: $name
)
}
}
struct NameField: View {
@Binding var name: String
var body: some View {
TextField(
"Name",
text: $name
)
}
}Common Interview Pitfalls
- Giving a parent and child separate mutable copies of the same logical state.
- Using @Binding when a child only needs a read-only input value.
- Using @State for data that should be owned by a longer-lived feature model.
- Trying to initialize or overwrite state from changing parent input on every render.
- Moving every piece of UI state into a global singleton.
- Passing bindings through many unrelated architectural layers without clear ownership.
- Assuming @Binding owns the value it exposes.
- Using local state for server truth that requires a dedicated data model and synchronization policy.
How should modern SwiftUI applications use observable model data while keeping state ownership clear?
Direct Answer
Use observable models for shared mutable feature state, keep model ownership at an intentional lifetime boundary, and let SwiftUI track the properties a view actually reads rather than duplicating model state.
Detailed Explanation
Modern SwiftUI can use Swift Observation so views form dependencies on observable model data and update when relevant observed properties change.
Observable models
A model can represent feature-level state and behavior rather than forcing a view to own every value individually.
Conceptually:
`swift
@Observable
final class CandidateModel {
var candidates: [Candidate] = []
var isLoading = false
}
A SwiftUI view that accesses observable properties establishes dependencies on that model data.
When relevant properties change, SwiftUI can update the affected view presentation.
Ownership still matters
Observation tells SwiftUI how changes are observed. It does not answer who should own the model or how long the model should live.
Ask:
The owner should create and retain the model for the required lifetime.
@State with observable models
For a model whose lifetime is owned by a SwiftUI view, modern Observation can work with SwiftUI state management so the model remains associated with the correct view identity.
The exact property-wrapper choice should follow the deployment target, Observation model, and ownership requirements.
Bindable editing
When a child needs bindings into properties of an observable model, SwiftUI provides mechanisms such as @Bindable for creating bindings to observable properties where appropriate.
Do not duplicate model properties into local state unnecessarily
This pattern can create two competing truths:
`text
model.name
view.localName
Sometimes a temporary editable draft is intentional. If so, define when the draft is initialized, committed, reset, or discarded.
Otherwise, read directly from the model.
Derived values should often remain derived
If filteredCandidates can be computed safely from candidates and searchText, storing all three independently can create synchronization bugs.
Store the minimal source state and derive presentation values when practical.
Legacy observation exists
Applications targeting older OS versions may still use ObservableObject and related Combine-era property wrappers.
Do not mix old and new observation models casually without understanding deployment targets and ownership semantics.
The architecture question remains the same in either system: who owns the data, who can mutate it, and which views depend upon it?
Code Example
import Observation
import SwiftUI
@Observable
final class CandidateListModel {
var candidates:
[Candidate] = []
var searchText = ""
var filteredCandidates:
[Candidate] {
guard
!searchText.isEmpty
else {
return candidates
}
return candidates.filter {
$0.name.localizedCaseInsensitiveContains(
searchText
)
}
}
}
struct CandidateListView: View {
@State private var model =
CandidateListModel()
var body: some View {
List(
model.filteredCandidates
) { candidate in
Text(candidate.name)
}
}
}Common Interview Pitfalls
- Assuming observation automatically determines the correct lifetime owner for a model.
- Duplicating observable model properties into local state without defining synchronization semantics.
- Persisting derived values independently and allowing them to drift from their source state.
- Putting unrelated features into one application-wide observable object.
- Recreating feature models unintentionally because ownership is attached to unstable view identity.
- Mixing legacy ObservableObject and modern Observation patterns without understanding their different deployment requirements.
- Treating every service or repository as observable UI state.
- Allowing views to mutate application state without defining which layer owns the operation.
How should navigation state, view identity, and lifecycle-driven work be modeled in a SwiftUI application?
Direct Answer
Treat navigation as application state where appropriate, preserve stable view identity, and connect asynchronous work to feature lifecycle without assuming view creation or appearance occurs only once.
Detailed Explanation
Navigation in SwiftUI can be modeled declaratively rather than being treated only as a sequence of imperative push commands.
Navigation hierarchy
NavigationStack represents hierarchical navigation where views can move through destinations according to navigation state.
A simple application may use navigation links directly.
A larger application may keep an explicit navigation path or route model when navigation itself must be controlled, restored, deep-linked, or tested.
Navigation is sometimes state
For example, an application might model:
`swift
enum Route: Hashable {
case candidate(UUID)
case application(UUID)
}
Then navigation can be derived from a collection of routes.
This is particularly useful when product requirements include:
Do not introduce a large routing abstraction when simple local navigation already meets the requirements.
Stable identity
Lists and dynamic UI require stable identifiers representing logical items.
Do not generate a new random identifier every time a computed model is accessed merely to satisfy Identifiable; doing so changes identity and can disrupt state, animation, selection, and rendering behavior.
Use identifiers that correspond to actual logical identity.
Lifecycle callbacks are not constructors
Views can appear, disappear, and be reevaluated as application state changes.
Do not assume callbacks such as appearance events execute exactly once during the complete lifetime of a feature.
task modifier
SwiftUI provides lifecycle-aware asynchronous task APIs for work associated with a view.
Use them when the asynchronous work genuinely belongs to that view lifecycle.
If the work must outlive the screen, move ownership into a longer-lived model, service, worker, or application-level owner instead.
Cancellation
Lifecycle-bound work should generally be cancellable when the UI no longer needs it.
Swift structured concurrency helps preserve that relationship.
Avoid loading the same data through several lifecycle paths
A common bug is starting identical work in initializers, onAppear, .task, and model constructors simultaneously.
Define one owner for loading behavior.
Navigation architecture should communicate user flow without turning SwiftUI navigation mechanics into business-domain dependencies.
Code Example
enum Route: Hashable {
case candidate(UUID)
}
struct CandidateListView: View {
@State private var path:
[Route] = []
var body: some View {
NavigationStack(
path: $path
) {
CandidateListContent {
candidate in
path.append(
.candidate(
candidate.id
)
)
}
.navigationDestination(
for: Route.self
) { route in
switch route {
case
.candidate(
let id
):
CandidateDetailView(
id: id
)
}
}
}
}
}Common Interview Pitfalls
- Generating unstable random IDs for logical list items on every render.
- Assuming SwiftUI views have UIKit-style one-time construction and destruction semantics.
- Starting the same network request from several lifecycle callbacks.
- Using global navigation state for a small feature that only needs local navigation.
- Keeping navigation entirely implicit when deep linking or restoration requires explicit route state.
- Running durable business work from a lifecycle-bound view task that is expected to be canceled.
- Treating appearance callbacks as guaranteed one-time initialization hooks.
- Putting SwiftUI NavigationStack types directly into the core domain model.
How do UIKit view controllers, layout, and lifecycle differ from SwiftUI, and how can the two frameworks interoperate?
Direct Answer
UIKit manages reference-based views and view controllers imperatively, while SwiftUI describes UI declaratively; Apple provides hosting and representable APIs for incremental interoperability.
Detailed Explanation
UIKit and SwiftUI provide different programming models, but production iOS applications can use them together.
UIKit
UIKit uses reference-based objects such as:
UIViewUIViewControllerUITableViewUICollectionViewApplication code configures these objects and responds to lifecycle events and user interaction.
UIViewController
A view controller coordinates a view hierarchy and participates in lifecycle events such as loading, appearance, disappearance, layout, and containment.
Do not put all domain/business logic into a view controller simply because UIKit provides lifecycle methods there.
Layout
UIKit commonly uses Auto Layout constraints or framework layout APIs to define relationships among views.
Developers need to understand view hierarchy, safe areas, intrinsic content size, constraints, and lifecycle timing when implementing UIKit screens.
SwiftUI
SwiftUI instead declares UI as a function of state.
Do not try to reproduce every UIKit mutation callback one-for-one inside a SwiftUI view.
SwiftUI inside UIKit
UIHostingController hosts a SwiftUI view hierarchy inside UIKit.
This allows an application with an existing UIKit navigation architecture to introduce SwiftUI screens incrementally.
Apple also provides UIHostingConfiguration for suitable UIKit content configurations, including collection/table-view scenarios.
UIKit inside SwiftUI
SwiftUI provides interoperability protocols such as:
UIViewRepresentableUIViewControllerRepresentableThese wrap UIKit views or view controllers so they participate in a SwiftUI hierarchy.
Representable lifecycle
A representable creates and updates UIKit objects according to SwiftUI state.
Do not recreate the UIKit object unnecessarily in the update phase merely because state changed.
Use the update callback to synchronize the existing UIKit object with the new SwiftUI inputs.
Coordinator
A representable can use a coordinator to bridge delegate/callback patterns between UIKit and SwiftUI.
Ownership must remain clear so that callbacks do not introduce cycles or duplicate state.
Incremental migration
An application does not need to rewrite every UIKit screen simultaneously to adopt SwiftUI.
A practical architecture can migrate feature-by-feature while maintaining explicit framework boundaries.
Choose interoperability based on product requirements and deployment constraints rather than treating one framework as universally superior.
Code Example
import SwiftUI
import UIKit
struct LegacyMapView:
UIViewRepresentable {
let coordinate:
CLLocationCoordinate2D
func makeUIView(
context: Context
) -> MKMapView {
MKMapView()
}
func updateUIView(
_ mapView: MKMapView,
context: Context
) {
let region =
MKCoordinateRegion(
center: coordinate,
latitudinalMeters:
2_000,
longitudinalMeters:
2_000
)
mapView.setRegion(
region,
animated: true
)
}
}
// UIKit can host SwiftUI:
let controller =
UIHostingController(
rootView:
CandidateView()
)Common Interview Pitfalls
- Treating SwiftUI and UIKit as mutually exclusive frameworks that cannot coexist in one application.
- Putting networking, persistence, and business logic directly into a large UIViewController.
- Recreating a wrapped UIKit view during every SwiftUI update instead of updating the existing instance.
- Duplicating state independently on both sides of a SwiftUI/UIKit bridge.
- Creating coordinator callbacks that form strong reference cycles.
- Trying to translate every UIKit imperative lifecycle callback directly into SwiftUI.
- Rewriting a stable large UIKit application all at once without a migration reason.
- Ignoring UIKit containment and lifecycle behavior when embedding view controllers.
How would you design a production iOS UI architecture that combines SwiftUI, UIKit, state ownership, navigation, concurrency, and feature boundaries without creating a massive global model?
Direct Answer
Give each feature an explicit state owner, keep views declarative, isolate domain and infrastructure concerns, model navigation intentionally, and bridge SwiftUI/UIKit only at clear boundaries.
Detailed Explanation
Production iOS UI architecture should make state ownership and feature responsibilities obvious.
The objective is not to force the entire application into one architectural acronym or framework.
1. Define feature boundaries
Organize related UI, state, behavior, and dependencies around product capabilities such as:
Do not make every feature depend directly on one enormous application model simply because observable state is convenient.
2. Keep views primarily declarative
A SwiftUI view should normally focus on:
Avoid direct database queries, raw URLSession construction, persistence migrations, and large business workflows inside body.
3. Give mutable state a clear owner
For each state value, answer:
Local transient UI state can remain in the view.
Feature-level mutable state can live in an observable model or another explicitly owned state object.
Application-level state should contain only concepts that genuinely require application scope.
4. Keep derived state derived where practical
Do not store several copies of the same truth merely because multiple screens need different presentations.
For example:
`text
all candidates
+ search query
→ filtered candidates
is often safer than separately mutating allCandidates and filteredCandidates after every change.
5. Separate UI state from domain and infrastructure
A useful conceptual flow is:
`text
SwiftUI / UIKit
↓
feature state / actions
↓
application/domain services
↓
repositories / networking / persistence
The exact number of layers should match application complexity.
Do not add layers that solve no concrete problem.
6. Keep concurrency ownership explicit
Feature models initiating asynchronous work should know whether the work belongs to the screen lifecycle, feature lifecycle, or application lifecycle.
Cancel UI-specific work when no longer needed.
Move durable work into longer-lived services or background infrastructure.
Do not hide task creation throughout helper functions when ordinary async propagation communicates ownership more clearly.
7. Keep UI mutation isolated appropriately
UI-facing state may be isolated to MainActor where its semantics require main-actor access.
Networking, parsing, and independent computation should not block UI responsiveness merely because a main-actor model initiated them.
Use Swift concurrency isolation rather than scattered manual main-queue dispatch where possible.
8. Model navigation according to requirements
For a small screen hierarchy, local NavigationLink behavior may be sufficient.
For deep links, restoration, authentication gating, or complex flows, explicit route/navigation state can make navigation testable and reproducible.
Do not build a global router abstraction solely because the application has more than one screen.
9. Maintain stable identity
Logical model identity should remain stable across renders.
Randomly regenerating identifiers can cause SwiftUI to interpret existing logical content as entirely different UI state.
10. Bridge UIKit deliberately
Existing UIKit applications can host SwiftUI through UIHostingController or other supported integration APIs.
SwiftUI can wrap UIKit components through representable protocols.
Keep the boundary explicit so each framework retains clear ownership of its lifecycle and state.
11. Avoid duplicate ownership across frameworks
If UIKit owns navigation while a hosted SwiftUI hierarchy maintains a second unrelated navigation model for the same flow, inconsistencies become likely.
Decide which side owns navigation and expose a narrow bridge.
The same principle applies to selection, presentation, and feature state.
12. Design observable models by feature, not convenience
A single application-wide observable object containing authentication, navigation, candidate lists, forms, alerts, network state, and settings creates broad invalidation and coupling.
Prefer focused models with explicit composition at higher levels.
13. Preserve testable business logic outside views
Core state transitions should be testable without rendering the complete application UI whenever possible.
For example, loading-state transitions or form-validation rules can be tested at the model/service level.
Use UI tests for interactions that genuinely require rendered UI behavior.
14. Handle failures as state
A feature commonly has meaningful states such as:
`text
idle
loading
loaded(data)
failed(error)
Model those states intentionally rather than scattering booleans such as:
`text
isLoading
hasLoaded
hasError
isEmpty
that can form contradictory combinations.
15. Keep transient and persistent state separate
Whether a sheet is open is not equivalent to server-backed account data.
Do not persist ephemeral visual state unless restoration requirements call for it.
Similarly, do not make durable user data depend solely on a view instance remaining alive.
16. Treat lifecycle as a feature requirement
Ask what happens when:
Architecture should define the expected outcome rather than relying on incidental framework behavior.
17. Avoid architecture-by-acronym
MVVM, coordinator patterns, unidirectional data flow, repository boundaries, and other techniques can all be useful.
The important questions are ownership, dependency direction, state transitions, testability, and lifecycle—not whether every screen follows a diagram mechanically.
18. Migrate incrementally
A mature UIKit application can adopt SwiftUI feature-by-feature.
Do not rewrite stable UI simply to achieve architectural uniformity unless product or maintenance value justifies the migration.
A strong production architecture keeps each feature understandable, makes state ownership explicit, keeps framework-specific concerns at the edge, and gives asynchronous work a clearly defined lifetime.
Code Example
import Observation
import SwiftUI
enum CandidateListState {
case idle
case loading
case loaded(
[Candidate]
)
case failed(
CandidateListError
)
}
@MainActor
@Observable
final class CandidateListModel {
private let repository:
CandidateRepository
private(set) var state:
CandidateListState =
.idle
init(
repository:
CandidateRepository
) {
self.repository =
repository
}
func load() async {
state = .loading
do {
let candidates =
try await repository
.candidates()
state =
.loaded(
candidates
)
} catch is CancellationError {
return
} catch {
state =
.failed(
.loadFailed
)
}
}
}
struct CandidateListView: View {
@State private var model:
CandidateListModel
init(
repository:
CandidateRepository
) {
_model = State(
initialValue:
CandidateListModel(
repository:
repository
)
)
}
var body: some View {
CandidateListContent(
state: model.state
)
.task {
await model.load()
}
}
}Common Interview Pitfalls
- Putting all application state into one global observable model for convenience.
- Creating raw networking, persistence, and business workflows directly in SwiftUI body implementations.
- Duplicating the same mutable source of truth across several feature models.
- Allowing navigation ownership to exist independently in both UIKit and SwiftUI for the same flow.
- Keeping every asynchronous task alive beyond the feature that owns it.
- Performing expensive work synchronously on the main actor because the view model is UI isolated.
- Generating unstable model identifiers and disrupting SwiftUI identity.
- Encoding mutually exclusive feature states as many independent booleans.
- Forcing every screen into an architectural pattern even when it adds no meaningful boundary.
- Rewriting an entire stable UIKit application rather than using supported incremental SwiftUI interoperability.
How should an iOS developer write useful unit tests with Swift Testing or XCTest?
Direct Answer
Unit tests should verify focused observable behavior with deterministic inputs; Swift Testing and XCTest both provide assertions and lifecycle tools for testing Swift application logic.
Detailed Explanation
Unit tests provide fast feedback about focused application behavior without requiring the entire UI or production infrastructure to run.
Apple currently provides two major testing APIs developers may encounter: Swift Testing and XCTest.
Swift Testing
Swift Testing uses Swift-native declarations such as:
`swift
@Test
func scoreCalculation() {
let result = calculateScore(
matches: 8,
total: 10
)
#expect(result == 80)
}
Tests can be organized using suites, traits, parameterized inputs, and other Swift Testing capabilities.
XCTest
XCTest remains widely used throughout Apple-platform projects.
A traditional XCTest case looks like:
`swift
final class ScoreTests:
XCTestCase {
func testScore() {
XCTAssertEqual(
calculateScore(
matches: 8,
total: 10
),
80
)
}
}
Test observable behavior
A useful test asks whether the externally meaningful behavior is correct.
For example, when testing a candidate-filtering feature, assertions might verify:
Avoid tightly coupling tests to private implementation details that can change without changing behavior.
Determinism matters
Dependencies such as:
can make tests unpredictable if accessed directly.
Introduce a controllable boundary when deterministic behavior matters.
For example, a feature that depends on the current date can receive a clock/date provider rather than reading global time everywhere.
Do not mock everything
Simple values, structures, pure functions, and inexpensive concrete collaborators often require no mocking.
Use test doubles where they help control a true side-effect boundary or verify an important interaction.
Test failure paths
Do not test only successful operations.
Useful tests include:
Keep tests independent
A test should not depend on another test executing first or leaving behind mutable shared state.
A strong unit-test suite makes business and feature behavior safe to change without becoming coupled to every implementation choice.
Code Example
import Testing
struct ScoreCalculator {
func calculate(
matches: Int,
total: Int
) -> Int {
guard total > 0 else {
return 0
}
return Int(
Double(matches)
/ Double(total)
* 100
)
}
}
@Test
func scoreCalculation() {
let calculator =
ScoreCalculator()
#expect(
calculator.calculate(
matches: 8,
total: 10
) == 80
)
}
@Test
func zeroTotalReturnsZero() {
let calculator =
ScoreCalculator()
#expect(
calculator.calculate(
matches: 0,
total: 0
) == 0
)
}Common Interview Pitfalls
- Testing private implementation details instead of observable behavior.
- Making unit tests depend on real production network services.
- Sharing mutable state between tests and depending on test execution order.
- Mocking every concrete value or helper without a meaningful reason.
- Testing only successful paths and ignoring errors or invalid inputs.
- Reading uncontrolled current time or randomness inside deterministic tests.
- Treating Swift Testing and XCTest as mutually exclusive technologies that cannot coexist during migration.
- Writing large tests that verify many unrelated behaviors at once.
What should iOS unit, integration, and UI tests each verify, and when should XCUITest-style UI testing be used?
Direct Answer
Unit tests verify focused logic, integration tests verify collaborating components, and UI tests exercise the running app through user-visible interactions where rendered behavior matters.
Detailed Explanation
Different test levels provide different kinds of confidence.
A healthy iOS test strategy does not force every behavior through the complete running application.
Unit tests
Use unit tests for focused logic such as:
These tests should generally be fast and deterministic.
Integration tests
Integration tests verify that meaningful components cooperate correctly.
Examples include:
The test may use controlled test infrastructure while still exercising real collaboration among components.
UI tests
XCTest UI-testing APIs launch and interact with the application from outside its process boundary.
Use UI tests when correctness depends on actual application interaction such as:
Do not test every business rule through UI tests
UI tests are generally slower and more sensitive to application lifecycle, environment, animations, and external dependencies.
A business validation rule that can be tested as a pure function should not require launching the whole app simply for completeness.
Accessibility supports testing
Stable accessibility labels and identifiers can make UI elements discoverable for automation while also supporting accessibility needs.
Do not use fragile coordinates or text that changes frequently when a stable semantic identifier is appropriate.
Control external state
UI tests should launch into a known environment.
Common techniques include test launch arguments, launch environment values, seeded data, or test-specific backend configuration.
Never let automated tests accidentally perform destructive actions against production user data.
Test critical user journeys selectively
High-value UI tests often cover flows such as:
`text
launch
→ authenticate/test session
→ open candidate
→ perform action
→ verify resulting UI state
Use lower-level tests for the many permutations of business logic beneath that flow.
The testing pyramid is not a rigid mathematical rule, but fast focused tests should usually provide most behavioral coverage while UI tests protect important end-to-end interactions.
Code Example
import XCTest
final class CandidateUITests:
XCTestCase {
func testOpenCandidate() {
let app =
XCUIApplication()
app.launchArguments = [
"-ui-testing"
]
app.launch()
app.buttons[
"candidate-list-item"
].firstMatch.tap()
XCTAssertTrue(
app.staticTexts[
"candidate-detail-title"
].waitForExistence(
timeout: 2
)
)
}
}Common Interview Pitfalls
- Testing every small business rule through the entire running UI.
- Using production backend data during automated UI tests.
- Depending on arbitrary screen coordinates instead of stable semantic element identification.
- Writing UI tests that require tests to execute in a specific order.
- Using long fixed sleeps instead of waiting for observable conditions.
- Testing only screenshots without verifying meaningful app behavior.
- Putting no integration tests between isolated unit tests and full UI tests.
- Allowing external services to make the test environment nondeterministic.
How should an iOS developer investigate a performance problem using XCTest performance tests, Xcode, and Instruments?
Direct Answer
Measure the actual symptom first, reproduce it with representative workloads, use Xcode and Instruments to identify CPU, memory, hangs, rendering, or I/O bottlenecks, then verify improvement.
Detailed Explanation
iOS performance work should begin with an observable problem rather than with assumptions about which API is slow.
Common user-visible symptoms include:
Establish the symptom
First determine what users experience and under which conditions.
For example:
`text
Candidate list scroll drops frames
when 5,000 records are displayed
is more actionable than:
`text
SwiftUI feels slow
XCTest performance tests
XCTest supports performance tests for repeatable code paths.
A measured operation can establish a baseline and detect regression over time.
Use performance tests for code that can be reproduced reliably, particularly when a known operation must stay within a reasonable performance range.
Instruments
Instruments provides specialized profiling templates and instruments for investigating application behavior.
Depending on the symptom, useful areas may include:
Choose the instrument based on the question you are asking rather than opening every profiler simultaneously.
Main-thread responsiveness
Long synchronous work on the main thread can cause delayed input, frame drops, and hangs.
Investigate what code occupies the main thread instead of assuming the fix is merely dispatching everything elsewhere.
UI work still needs correct isolation, and moving unsafe shared state to background execution can introduce correctness problems.
Memory
High memory can result from:
A retain cycle and legitimate high working-set memory are different problems and should be diagnosed differently.
Representative data
Profile realistic devices and datasets.
A feature that performs well with 10 objects in the simulator can behave very differently with thousands of objects on a physical device.
Release behavior matters
Debug builds and simulator execution can differ materially from optimized device builds.
Use appropriate release-like conditions when diagnosing production performance.
Measure after the change
Every performance optimization should answer:
Performance engineering should follow evidence rather than preference for a particular framework or syntax.
Code Example
import XCTest
final class SearchPerformanceTests:
XCTestCase {
func testFilteringPerformance() {
let candidates =
makeCandidates(
count: 10_000
)
measure {
_ = candidates.filter {
$0.name
.localizedCaseInsensitiveContains(
"swift"
)
}
}
}
}Common Interview Pitfalls
- Rewriting SwiftUI views before profiling which operation actually causes the slowdown.
- Profiling only simulator debug builds and assuming the results represent production devices.
- Moving all work off the main thread without considering actor isolation or data safety.
- Treating every memory increase as a retain cycle.
- Benchmarking tiny unrealistic datasets.
- Opening profiling tools without first defining the performance symptom.
- Optimizing implementation details without measuring before and after.
- Ignoring battery, disk, and network costs while focusing only on CPU.
How can MetricKit and Apple performance diagnostics help monitor an iOS app after release?
Direct Answer
MetricKit provides performance and diagnostic information from real-device usage, helping teams analyze issues such as launches, hangs, memory, CPU, crashes, and other production behavior.
Detailed Explanation
Development-time profiling is necessary but cannot reproduce every production workload, device condition, or user behavior.
Apple provides production-oriented performance and diagnostic tooling including MetricKit.
MetricKit
MetricKit exposes reports containing performance and diagnostic information collected by the operating system from real application usage.
Depending on platform and report capabilities, data can include information related to areas such as:
Real-world context matters
An application can perform well on a developer device but fail under:
Production metrics help identify those patterns.
Metric reports are aggregate diagnostics, not user analytics
Use MetricKit for performance and diagnostic understanding rather than treating it as a replacement for arbitrary product-event analytics.
Correlate reports with releases
If memory, hang rate, or launch behavior changes after a release, correlate diagnostics with:
This helps determine whether the regression corresponds to a specific code or product change.
Signposts
Developers can instrument important application operations so performance investigations can reason about meaningful feature intervals rather than only low-level call stacks.
For example, a candidate-import operation might have a signposted interval that can be related to performance metrics.
Protect privacy
Diagnostics should not become an excuse to record secrets, tokens, or unnecessary personal information.
Use operational identifiers and metadata only when justified and handled according to the application privacy model.
Close the loop
A healthy production performance process is:
`text
release
→ observe production metrics
→ identify regression
→ reproduce/profile
→ fix
→ verify next release
Do not rely solely on App Store reviews or support tickets to discover technical regressions.
Code Example
import MetricKit
final class MetricsSubscriber:
NSObject,
MXMetricManagerSubscriber {
func start() {
MXMetricManager.shared
.add(self)
}
func didReceive(
_ payloads:
[MXMetricPayload]
) {
for payload in payloads {
process(payload)
}
}
func didReceive(
_ payloads:
[MXDiagnosticPayload]
) {
for payload in payloads {
process(payload)
}
}
private func process(
_ payload: MXMetricPayload
) {
// Store or aggregate
// permitted metrics.
}
private func process(
_ payload:
MXDiagnosticPayload
) {
// Analyze diagnostics.
}
}Common Interview Pitfalls
- Assuming development-time profiling alone represents every production device and workload.
- Treating MetricKit as a general-purpose user analytics replacement.
- Ignoring performance regressions because the app does not crash.
- Looking at production metrics without correlating them to application versions or releases.
- Collecting sensitive data unnecessarily alongside performance diagnostics.
- Waiting only for customer complaints before investigating hangs or launch regressions.
- Treating aggregate metrics as proof of one specific code-level root cause without further investigation.
- Collecting diagnostics without an operational process for acting on regressions.
How should a production iOS app handle lifecycle transitions, background work, cancellation, state preservation, and unreliable execution time?
Direct Answer
Treat foreground and background transitions as explicit lifecycle events, persist durable state before relying on memory, cancel irrelevant UI work, and use supported background APIs rather than assuming unlimited execution.
Detailed Explanation
Mobile applications run under tighter lifecycle and resource constraints than long-lived server processes.
A production iOS architecture must assume the process can move between states and may eventually be suspended or terminated.
Do not rely on unlimited process lifetime
In-memory state should not be the only copy of data that users expect to survive relaunch.
Durable user data belongs in an appropriate persistence layer or server-backed system.
UI-scoped work
Work that exists only to populate a screen should normally follow that feature lifetime.
If the user leaves the screen and the result is no longer useful, cancel the operation when appropriate.
Do not allow many obsolete search or image-loading requests to continue simply because they were already started.
Durable work
Some work must survive beyond one screen, such as:
Move this work into an owner whose lifetime matches the requirement and use Apple-supported background mechanisms when applicable.
Background execution is constrained
Do not assume that entering the background grants arbitrary additional execution time.
Use the supported APIs appropriate to the workload, and design operations to tolerate suspension, expiration, or later retry.
Save critical state deliberately
When application state must survive termination, persist it at meaningful consistency points rather than relying solely on a termination callback that may not always provide the desired opportunity.
Idempotency and restartability
Long-running workflows should preferably know whether they can safely resume or retry after interruption.
For example, an upload operation can store:
rather than depending on one in-memory boolean.
Lifecycle and networking
A request that finishes after a screen disappears should not blindly mutate UI that no longer owns the result.
Task ownership and actor isolation should express where the result belongs.
Memory pressure
Caches should be able to release recreatable data when appropriate.
Do not treat a cache as permanent persistence simply because retrieving the data again is inconvenient.
Test lifecycle behavior
Exercise conditions such as:
Reliability on iOS means assuming execution can be interrupted and ensuring important state has an owner beyond transient UI memory.
Code Example
actor UploadRepository {
private let store:
UploadStateStore
func begin(
item: UploadItem
) async throws {
let operation =
UploadOperation(
id: UUID(),
itemID: item.id,
state: .pending
)
try await store.save(
operation
)
do {
try await upload(
operation
)
try await store.markComplete(
operation.id
)
} catch {
try await store.markFailed(
operation.id
)
throw error
}
}
}Common Interview Pitfalls
- Assuming an iOS process will remain alive until every background task finishes.
- Keeping durable user data only in transient SwiftUI or UIKit state.
- Continuing obsolete UI-specific work after the owning screen disappears.
- Starting background work without using an execution mechanism appropriate to the workload.
- Depending solely on an application-termination callback to persist critical state.
- Designing non-restartable long-running operations with no persisted progress.
- Updating UI state after its feature lifetime has ended without checking ownership.
- Using an in-memory cache as though it were durable persistence.
How would you design, test, profile, ship, and evolve a large production iOS application used by multiple teams?
Direct Answer
Define feature and dependency boundaries, use layered tests, make concurrency ownership explicit, measure performance on real workloads, monitor production health, and evolve contracts incrementally.
Detailed Explanation
Large production iOS applications need architecture that supports both runtime correctness and organizational scale.
A design that works for one screen can fail when hundreds of engineers, many features, and long-lived releases share the same application.
1. Organize around coherent features
Define clear modules or feature boundaries such as:
Avoid one enormous target where every feature can import and mutate every other feature.
2. Control dependency direction
Feature/UI code may depend on focused application capabilities.
Infrastructure should implement those capabilities rather than forcing domain behavior to know details about URLSession, database schemas, analytics SDKs, or UIKit navigation.
Conceptually:
`text
UI / Feature
↓
Application contracts
↓
Infrastructure implementations
The exact layering should remain proportional to the application complexity.
3. Make ownership explicit
Every important object should have a clear lifetime:
This includes:
Unclear ownership causes leaks, stale state, and duplicated work.
4. Use Swift concurrency deliberately
Prefer structured concurrency for work belonging to an operation.
Use actors for meaningful shared mutable state.
Use Sendable boundaries to communicate safe cross-isolation data.
Do not solve compiler diagnostics with unsafe annotations unless the synchronization invariant is proven externally.
5. Separate UI state from durable data
SwiftUI state should describe UI concerns.
Persistent account or workflow state belongs in repositories, persistence, or server-backed systems according to product requirements.
A view disappearing should not destroy durable business state accidentally.
6. Design test layers
Use Swift Testing or XCTest for focused domain and feature behavior.
Use integration tests for meaningful collaboration such as:
Use UI tests for critical user journeys where real interaction matters.
Do not make every behavior depend on a slow UI test.
7. Test failure paths
Production incidents rarely occur only in successful flows.
Test scenarios such as:
8. Measure performance continuously
Use performance tests for repeatable regressions and Instruments for detailed profiling.
Measure real-device behavior for:
Do not infer performance solely from code style.
9. Monitor shipped behavior
Use Xcode Organizer metrics, MetricKit, crash diagnostics, and other appropriate operational telemetry to identify regressions that only occur in production.
Relate regressions to app versions and feature changes.
10. Treat performance budgets as product requirements
For important workflows, define what acceptable performance means.
Examples can include:
The exact thresholds should come from product experience and measured devices rather than arbitrary universal numbers.
11. Control third-party SDKs
Every analytics, advertising, networking, attribution, or monitoring SDK adds potential:
Wrap SDK boundaries where useful and measure their real operational impact.
12. Preserve privacy
Telemetry, logs, screenshots, network diagnostics, and crash metadata must not casually expose secrets or sensitive user content.
Data minimization should be part of observability architecture.
13. Design offline and degraded behavior intentionally
Not every feature must work fully offline, but the app should define behavior when the network is unavailable.
Possible behaviors include:
Do not let accidental URLSession failure behavior define the user experience.
14. Evolve persisted schemas carefully
Local database or encoded-storage changes may affect users upgrading from versions released months earlier.
Migration logic should account for realistic upgrade paths rather than only fresh installations.
Test migrations using representative old-state fixtures.
15. Evolve server contracts compatibly
Mobile clients may remain installed for long periods.
Backend APIs should not assume every user updates immediately.
Prefer additive/backward-compatible changes when possible and explicitly plan removal of old contracts.
16. Use feature rollout carefully
Feature flags and staged releases can limit incident impact.
Do not let feature flags become permanent uncontrolled branching throughout the codebase.
Define ownership and eventual cleanup.
17. Design for app lifecycle interruption
The process can background, suspend, receive memory pressure, lose connectivity, and later relaunch.
Critical workflows should be restartable or recoverable according to their business semantics.
18. Keep UIKit/SwiftUI boundaries explicit
Large apps may contain both frameworks for years.
Use supported bridging and define one owner for navigation and state in each flow rather than maintaining duplicate truth on both sides.
19. Make builds and tests scalable
As the codebase grows, module boundaries can improve ownership and potentially reduce unnecessary dependency recompilation, but over-fragmentation also creates maintenance cost.
Create modules around meaningful ownership and dependency boundaries rather than one module per file or screen.
20. Treat architecture as an evolving constraint system
Do not freeze one pattern permanently.
As product and team scale changes, review:
Change architecture when evidence shows that current boundaries no longer serve the product.
A mature iOS platform makes feature ownership understandable, limits shared mutable state, tests important behavior at the right level, remains responsive under realistic conditions, and can evolve without requiring every team to understand the entire application.
Code Example
protocol CandidateRepository:
Sendable {
func candidates()
async throws
-> [Candidate]
}
struct Candidate:
Identifiable,
Sendable {
let id: UUID
let name: String
}
@MainActor
@Observable
final class CandidateFeatureModel {
enum State {
case idle
case loading
case loaded(
[Candidate]
)
case failed
}
private let repository:
any CandidateRepository
private(set) var state:
State = .idle
init(
repository:
any CandidateRepository
) {
self.repository =
repository
}
func load() async {
state = .loading
do {
let candidates =
try await repository
.candidates()
try Task
.checkCancellation()
state =
.loaded(
candidates
)
} catch is CancellationError {
return
} catch {
state = .failed
}
}
}Common Interview Pitfalls
- Putting all features and infrastructure into one unrestricted module with arbitrary dependency direction.
- Using global mutable singleton state as the easiest way to share data across teams.
- Suppressing Swift concurrency safety diagnostics instead of correcting ownership boundaries.
- Keeping durable business state only inside transient SwiftUI view state.
- Running every behavioral scenario through slow full-app UI tests.
- Profiling only simulator debug builds and assuming they reflect customer performance.
- Shipping performance-sensitive changes without production monitoring.
- Adding third-party SDKs without measuring launch, privacy, network, or binary-size impact.
- Breaking backend contracts because the newest app version supports the replacement.
- Testing database migrations only from a clean installation.
- Keeping feature flags permanently after a rollout completes.
- Forcing a complete UIKit-to-SwiftUI rewrite when incremental boundaries are safer.
Want to tailer your resume for iOS Developer (Swift) roles?
Import your resume, scan it for critical iOS Developer (Swift) keywords, and compare it against ATS standards instantly.