Skip to content
Notes · Roadmap

The road to Senior iOS.

My own study notes, laid out as a path from the basics to senior-level craft. Every topic has a plain-language explanation, an analogy you can picture, a little drawing, real Swift code, the words to know, and what to remember. Walk the 5 stages in order, or jump around. Then test yourself: the quiz pulls a fresh set of 20 from 160 Swift questions and reshuffles every round.

  1. 1Foundations

    The vocabulary every iOS dev uses every single day.

    5 topics →

  2. 2Core Swift

    The building blocks that make code reusable and expressive.

    6 topics →

  3. 3Under the hood

    How your app manages memory, threads, and data, where bugs hide.

    4 topics →

  4. 4Frameworks & data

    Building screens and moving data the way real apps do.

    5 topics →

  5. 5Senior craft

    Architecture, patterns, and habits that scale a codebase and a team.

    8 topics →

Your progress

0/28 topics

0%

1
Stage 1 of 5

Foundations

The vocabulary every iOS dev uses every single day.

0/5 done

native

Variables & Constants (let / var)

Two kinds of boxes to store values: one you can change, one you can't.

Think of it like…

Think of `let` as writing in pen, once it's down, it stays. `var` is writing in pencil, you can erase and rewrite it. Use pen by default; only grab the pencil when you actually need to change something.

A variable is just a labelled box that holds a value. `var name = "Ali"` makes a box you can refill later. `let name = "Ali"` makes a box that's sealed after the first value.

Swift figures out the type of the box automatically from what you put in it (this is called type inference), so you rarely write the type yourself. You can if you want clarity: `let age: Int = 8`.

Prefer `let`. It tells anyone reading the code "this never changes", which prevents bugs and lets the compiler optimise. Reach for `var` only when the value genuinely needs to change over time.

Picture it
  let  (pen, sealed)          var  (pencil, refillable)
 ┌───────────────┐          ┌───────────────┐
 │  name = "Ali" │          │  score = 0    │
 └───────────────┘          └───────────────┘
        │                          │
   change it? ──► ❌ error    change it? ──► ✅ score = 10
let rejects a second assignment; var accepts it.
Swift
let pi = 3.14159        // constant, type inferred as Double
var score = 0           // variable, can change
score = 10              // ✅ allowed
// pi = 3.0             // ❌ compile error: cannot assign to 'let'

let name: String = "Ali"   // explicit type (optional to write)
let = can't reassign. var = can. Type is usually inferred.
Words to know
Constant.
A value that never changes after it's set (let).
Variable.
A value you can reassign later (var).
Type inference.
Swift guessing the type from the value, so you don't write it.
Remember this
  • Default to `let`; use `var` only when the value must change.
  • Swift infers the type, `let x = 5` is an Int automatically.
  • Reassigning a `let` is a compile-time error (caught before the app runs).
native

Optionals

A value that might be there… or might be nothing (nil). Swift's safety belt.

Think of it like…

An optional is a wrapped gift box. It might have a toy inside, or it might be empty. Before you play with the toy, you have to open the box and check. Swift won't let you play with a toy you haven't confirmed is there, that's how it stops the classic 'crash on nothing' bug.

Lots of things in real apps can be missing: a text field the user left blank, a profile photo that didn't load, a number that failed to parse. Swift marks 'might be missing' with a `?`, that's an Optional.

`var age: Int?` means 'an Int, or nil (nothing)'. You can't use it directly as a number, first you must unwrap it (open the box) and handle the empty case.

The safe ways to unwrap are `if let` and `guard let` (open the box only if there's something inside), and `??` (provide a fallback). Avoid `!` (force unwrap), that's ripping the box open and crashing if it's empty.

Picture it
   Optional<Int>  =  a box that may hold an Int

   Some(5)                       None (nil)
 ┌──────────┐                 ┌──────────┐
 │   [ 5 ]  │                 │  (empty) │
 └──────────┘                 └──────────┘
      │                            │
  if let n = box  ──► n = 5    if let n = box ──► skipped (safe)
      │
  use n safely ✅
Unwrapping runs your code only when the box actually has a value.
Swift
var age: Int? = nil          // empty box right now
age = 8

// Safe unwrap with if let
if let realAge = age {
    print("Age is \(realAge)")   // runs only if not nil
} else {
    print("No age given")
}

// guard let, exit early if missing
func greet(_ name: String?) {
    guard let name else { return }   // bail if nil
    print("Hi \(name)")             // name is safe below
}

// Nil-coalescing: provide a default
let shown = age ?? 0          // 8 if set, else 0
if let / guard let / ?? are the safe ways. Force-unwrap (!) can crash.
Words to know
Optional.
A type that can hold a value or nil. Written with ? (e.g. Int?).
nil.
The absence of a value, 'nothing here'.
Unwrap.
Safely getting the value out of an optional after checking it exists.
Force unwrap (!).
Assuming a value is present; crashes if it's nil. Avoid.
Nil-coalescing (??).
Use the value if present, otherwise a fallback.
Remember this
  • `?` means 'might be nil'. You must unwrap before using it.
  • Prefer `if let` / `guard let` / `??`. Avoid `!` (force unwrap can crash).
  • Optionals are why Swift apps crash far less than older Objective-C apps.
native

Collections (Array, Dictionary, Set)

The three everyday containers for holding many values.

Think of it like…

An Array is a numbered train: carriages in order, each with a seat number (index). A Dictionary is a coat-check: you hand over a ticket (key) and get back your coat (value), no order needed. A Set is a bag of unique stickers: no duplicates, no particular order.

Array keeps items in order and lets you reach any of them by index (starting at 0). Use it when order matters or you need duplicates.

Dictionary stores key, value pairs for fast lookup by key. Looking up a missing key returns nil (an optional), so you handle 'not found' safely.

Set stores only unique values with very fast 'is this in here?' checks, but no order. Reach for it when membership and uniqueness matter more than sequence.

Picture it
Array  [a, b, c]      index:  0   1   2     ordered, dupes ok
Dict   ["ali": 8]     key "ali" ──► 8        lookup by key
Set    {🍎,🍌}         contains 🍎? ──► true   unique, unordered
Pick by the question you ask: position, key, or membership.
Swift
var fruits = ["apple", "pear"]      // [String]
fruits.append("plum")
print(fruits[0])                    // "apple"

var ages = ["Ali": 8, "Sam": 9]     // [String: Int]
print(ages["Ali"] ?? 0)             // 8 (optional lookup)
ages["Sam"] = 10                    // update

var seen: Set = [1, 2, 2, 3]        // {1, 2, 3} - dupes dropped
print(seen.contains(2))             // true
Array by index, Dictionary by key (optional), Set for uniqueness.
Words to know
Array.
Ordered list reached by integer index, allows duplicates.
Dictionary.
Key, value pairs; subscript returns an optional value.
Set.
Unordered collection of unique values with fast membership tests.
Index.
The position of an element in an array, starting at 0.
Remember this
  • Array = order + duplicates; Dictionary = lookup by key; Set = unique + fast contains.
  • Dictionary subscript returns an optional, so 'missing key' is handled safely.
  • All three are value types (copied), backed by copy-on-write for efficiency.
native

Enums & Associated Values

A type with a fixed list of possible states, and each state can carry data.

Think of it like…

An enum is like a traffic light: it can only ever be red, yellow, or green, never 'banana'. Associated values are like a parcel that carries extra info: a 'delivered' state can also hold the date it arrived.

Enums let you model 'one of a few known options' precisely. Instead of loose strings like "loading"/"done" (easy to typo), you get compiler-checked cases.

Each case can carry its own associated data. `.failure(Error)` holds the error; `.success(Data)` holds the result. This is how Swift's own `Result` type works.

`switch` over an enum must handle every case (it's exhaustive), so when you add a new case the compiler flags every place you forgot to update, a huge safety win.

Picture it
enum Light { case red, yellow, green }

      ┌─────┬────────┬───────┐
      │ red │ yellow │ green │   ← only these, ever
      └─────┴────────┴───────┘

enum Load { case idle, loading, done(String), failed(Error) }
                                  └─ carries data ─┘
Fixed set of states; some states carry a payload.
Swift
enum Status {
    case idle
    case loading
    case done(String)        // carries the result text
    case failed(Error)       // carries the error
}

func handle(_ s: Status) {
    switch s {                       // must cover every case
    case .idle:           print("waiting")
    case .loading:        print("…")
    case .done(let text): print("got \(text)")
    case .failed(let e):  print("error \(e)")
    }
}
switch is exhaustive, add a case and the compiler finds every spot to fix.
Words to know
Enum.
A type whose value is one of a fixed set of named cases.
Associated value.
Extra data attached to a specific case, e.g. .done("hi").
Raw value.
A fixed underlying value per case, e.g. enum Dir: Int { case n = 0 }.
Exhaustive switch.
A switch that must handle every possible case.
Remember this
  • Enums model a fixed set of states safely (no typos like loose strings).
  • Cases can carry data (associated values), great for results and events.
  • Exhaustive switch = the compiler reminds you when you add a case.
native

Value vs Reference Types (struct vs class)

Copies vs sharing, the single most important model to get right in Swift.

Think of it like…

A struct is like handing someone a photocopy of your notes: they scribble on their copy, yours is untouched. A class is like sharing one Google Doc: anyone with the link edits the same document, so a change one person makes shows up for everyone.

When you assign or pass a struct, Swift makes a COPY. The two now live independent lives, changing one never affects the other. Structs are 'value types' (so are Int, String, Array, Bool…).

When you assign or pass a class, both names point to the SAME object in memory. Change it through one name and the other sees the change too. Classes are 'reference types'.

Swift's advice: prefer structs. They're simpler and safer because there's no spooky 'someone else changed my data' surprise. Use a class when you genuinely need shared, identity-based state (or need inheritance).

Picture it
STRUCT (copy)                 CLASS (shared)
 a ─► [ x: 1 ]                 a ─┐
 b = a  (COPY)                    ├─► [ x: 1 ]   one object
 b ─► [ x: 1 ]                 b ─┘
 b.x = 9                       b.x = 9
 a.x = 1  ✅ unchanged          a.x = 9  ⚠️ also changed!
Struct: two boxes. Class: two labels on one box.
Swift
struct PointS { var x = 0 }
class  PointC { var x = 0 }

var a = PointS(); var b = a   // COPY
b.x = 9
print(a.x)   // 0 , a untouched

let c = PointC(); let d = c   // SAME object
d.x = 9
print(c.x)   // 9 , changed through d
Same code shape, opposite behaviour. Struct copies; class shares.
Words to know
Value type.
Copied on assignment/passing (struct, enum, Int, String, Array).
Reference type.
Shared via a pointer; many names, one object (class).
Identity.
Whether two references point to the exact same object (=== checks this).
Mutation.
Changing stored data. With structs you often mark methods `mutating`.
Remember this
  • struct = copy (independent). class = shared (same object).
  • Default to struct; use class for shared identity or inheritance.
  • Most of Swift's standard library (Array, String, Int) are value types.
2
Stage 2 of 5

Core Swift

The building blocks that make code reusable and expressive.

0/6 done

native

Closures

A chunk of code you can store in a variable and run later.

Think of it like…

A closure is a little backpack of instructions. You can hand the backpack to someone (pass it to a function), they carry it around, and open it later to run the steps inside. It even remembers the things it packed from where it was created (it 'captures' them).

A function is code with a name. A closure is the same idea without needing a name, you can put it in a variable, pass it as an argument, or return it. This is how iOS does 'do this when the download finishes' or 'run this when the button is tapped'.

Closures 'capture' values from around them, they remember variables that were in scope when they were created, even after that scope is gone. That's the backpack: it keeps what it packed.

Swift has shorthand. Trailing-closure syntax lets you write the closure after the parentheses, and `$0`, `$1` are the first/second arguments. That's why `map`, `filter`, `sorted` read so cleanly.

Picture it
   { (input) -> output in  ... steps ... }
        │            │            │
      params      returns       body

  let greet = { (name: String) in print("Hi \(name)") }
  greet("Ali")                    ──► runs the backpack now

  download(url) { data in ... }   ──► runs LATER, when done
A closure is stored now, run whenever it's called.
Swift
// Stored in a variable, called later
let add = { (a: Int, b: Int) -> Int in a + b }
print(add(2, 3))     // 5

// Passed to a function (trailing-closure + shorthand $0)
let nums = [3, 1, 2]
let sorted = nums.sorted { $0 < $1 }   // [1, 2, 3]
let doubled = nums.map { $0 * 2 }      // [6, 2, 4]

// Captures surrounding state
func counter() -> () -> Int {
    var count = 0
    return { count += 1; return count }   // backpack keeps 'count'
}
let next = counter()
print(next(), next())   // 1 2
map/filter/sorted take closures. $0 is the first argument.
Words to know
Closure.
An unnamed block of code that can be stored and passed around.
Capture.
A closure remembering variables from where it was created.
Trailing closure.
Writing the closure after the function's parentheses for readability.
Higher-order function.
A function that takes or returns another function/closure (map, filter).
Remember this
  • Closures = code you can pass around and run later (callbacks, completion handlers).
  • They capture and remember surrounding variables.
  • `$0`/`$1` and trailing-closure syntax make map/filter/sorted concise.
native

Properties (stored, computed, lazy, observers)

The different kinds of values a type can hold or calculate.

Think of it like…

A stored property is money in your wallet, it just sits there. A computed property is your bank balance on the app: nothing is stored on your phone, it works it out fresh each time you look. An observer (didSet) is a doorbell: it rings the moment the value changes so you can react.

A stored property holds an actual value. A computed property has no storage; it runs a getter to calculate its value every time it's read (and an optional setter to write).

A lazy stored property delays its work until first accessed, handy for expensive setup you might not always need.

Property observers willSet and didSet let you run code right before/after a stored value changes, perfect for keeping a UI or cache in sync.

Picture it
stored      var name = "Ali"        ← value sits in memory
computed    var area: Double {       ← recalculated on read
              width * height
            }
observer    var score = 0 {
              didSet { print("now \(score)") }  ← runs on change
            }
lazy        lazy var heavy = build()  ← built on first use only
Stored holds; computed calculates; observers react; lazy defers.
Swift
struct Rectangle {
    var width = 0.0
    var height = 0.0
    var area: Double { width * height }   // computed (read-only)
}

class Player {
    var score = 0 {
        didSet { print("score changed to \(score)") }  // observer
    }
    lazy var profile = loadProfile()      // built on first access
}
Computed = no storage; didSet = react to change; lazy = build on demand.
Words to know
Stored property.
A property that holds a value in memory.
Computed property.
A property calculated by a getter each time it's read.
lazy.
A stored property initialised only on first access (must be var).
willSet / didSet.
Observers that run just before / after a value changes.
Remember this
  • Stored holds a value; computed calculates one on every read.
  • `lazy` defers expensive setup until the property is first used.
  • `didSet`/`willSet` react to changes, great for syncing UI or caches.
hybrid

Extensions

Add new abilities to a type you already have, even one you didn't write.

Think of it like…

An extension is like adding a new app to a phone you already own. You don't rebuild the phone; you just give it a fresh ability. Swift lets you add abilities even to Apple's own types, like teaching Int a new trick.

Extensions add methods, computed properties, initialisers, and protocol conformance to an existing type, without editing its original source. You can even extend types you don't own (Int, String, Array).

They can't add stored properties (no room for new storage), but computed properties are fine.

A common, tidy pattern is to put each protocol conformance in its own extension, so related methods are grouped and easy to find.

Picture it
Int  ──extension──►  now has .squared()

extension Int { var squared: Int { self * self } }
3.squared   ──►  9

extension MyView: Drawable { func draw() { ... } }  ← grouped conformance
Bolt new behaviour onto an existing type, no source edits.
Swift
extension Int {
    var squared: Int { self * self }
    func times(_ run: () -> Void) {       // add a method
        for _ in 0..<self { run() }
    }
}

print(4.squared)        // 16
3.times { print("hi") } // hi hi hi

// Group protocol conformance in its own extension
extension String: MyProtocol { /* ... */ }
Add methods/computed props to any type; group conformances cleanly.
Words to know
Extension.
A block that adds functionality to an existing type.
Retroactive modelling.
Making a type you don't own conform to your protocol via an extension.
Conformance extension.
An extension dedicated to one protocol's methods, for tidiness.
Remember this
  • Add methods, computed properties, and conformances without touching the original type.
  • You can extend types you don't own (Int, String, Array, …).
  • No stored properties in extensions; computed ones are fine.
hybrid

Protocols & Protocol-Oriented Programming

A contract: a list of abilities a type promises to provide.

Think of it like…

A protocol is a job checklist. A 'Driver' job requires: can start engine, can steer, can brake. Anyone, a human, a robot, a self-driving car, can take the job as long as they tick every box. The boss doesn't care WHO does it, only that the checklist is met.

A protocol defines WHAT must be possible (method/property names) without saying HOW. Types then 'conform' by providing the how. This lets unrelated types be used interchangeably as long as they meet the contract.

This is how Swift achieves flexibility without inheritance. You program to the checklist (the protocol), so you can swap implementations freely, real network code in the app, a fake one in tests.

Protocol extensions let you give default behaviour to every conformer for free. That combination, small contracts + shared defaults, is 'protocol-oriented programming', Swift's signature style.

Picture it
protocol Drawable { func draw() }   ← the checklist

   Circle ──┐
   Square ──┼──► all promise draw() ──► used as 'Drawable'
   Robot  ──┘

   func render(_ shapes: [Drawable]) { for s in shapes { s.draw() } }
        └─ doesn't care about the concrete type, only the contract
Many different types, one shared contract.
Swift
protocol Greeter {
    var name: String { get }
    func greet()
}

extension Greeter {                 // default behaviour for all
    func greet() { print("Hi, I'm \(name)") }
}

struct Dog: Greeter { let name = "Rex" }      // gets greet() free
struct Robot: Greeter {
    let name = "R2"
    func greet() { print("BEEP \(name)") }   // or override it
}

let crowd: [Greeter] = [Dog(), Robot()]
crowd.forEach { $0.greet() }
Conform to a protocol; extensions hand you default implementations.
Words to know
Protocol.
A contract of required properties/methods, no implementation.
Conform.
A type providing everything a protocol requires.
Protocol extension.
Default implementations shared by all conformers.
Polymorphism.
Treating different types uniformly through a shared protocol.
Remember this
  • Protocols describe abilities; types conform by implementing them.
  • Lets you swap implementations (real vs mock), key for testing.
  • Protocol extensions add free default behaviour: 'protocol-oriented programming'.
hybrid

Generics

Write code once that works for any type, without losing type safety.

Think of it like…

A generic is a lunchbox shaped to fit 'any food'. You don't build a separate box for rice, another for sandwiches, another for fruit, one box, labelled with whatever you put in today. Swift's Array is exactly this: Array<Int>, Array<String>, same box, different contents.

Without generics you'd copy-paste the same function for Int, then String, then Double. Generics let you write it ONCE with a placeholder type (usually `T`), and Swift stamps out a correct version for each real type you use.

Crucially you keep full type safety, a `Stack<Int>` only accepts Ints, and what comes out is known to be an Int. No casting, no 'any' soup.

You can add constraints: `func max<T: Comparable>(…)` means 'T works as long as it can be compared'. That's how `sorted`, `Array`, `Dictionary`, and `Optional` are all built.

Picture it
   Stack<T>   ← T is a placeholder for "whatever type"

   Stack<Int>        Stack<String>
 ┌──────────┐      ┌────────────┐
 │ 3, 1, 2  │      │ "a", "b"   │
 └──────────┘      └────────────┘
   push/pop Int      push/pop String   (same code, type-safe)
One implementation, specialised safely per type.
Swift
struct Stack<T> {            // T = any type
    private var items: [T] = []
    mutating func push(_ x: T) { items.append(x) }
    mutating func pop() -> T? { items.popLast() }
}

var ints = Stack<Int>()
ints.push(1); ints.push(2)
print(ints.pop() ?? -1)      // 2

// Constraint: T must be Comparable
func biggest<T: Comparable>(_ a: T, _ b: T) -> T { a > b ? a : b }
print(biggest(3, 9))         // 9
print(biggest("a", "z"))     // "z"
<T> is the placeholder; <T: Comparable> adds a requirement.
Words to know
Generic.
Code parameterised by a type placeholder (T) so it works for many types.
Type parameter.
The placeholder name, conventionally T, Element, Key, Value.
Constraint.
A requirement on the placeholder, e.g. T: Comparable.
Remember this
  • Write once, reuse for any type, without giving up type checking.
  • Array, Dictionary, Optional, Result are all generics.
  • Add constraints (T: Comparable) to require specific abilities.
backend

Error Handling (do / try / catch)

A clean path for 'this might fail' that never silently ignores problems.

Think of it like…

It's a smoke alarm wired to a plan. A risky action (cooking) might `throw` smoke. You wrap it in a `do` (the kitchen), mark the risky step with `try`, and a `catch` block is your fire plan for when something goes wrong, so a failure is handled, not ignored.

Functions that can fail are marked `throws`. To call them you write `try`, which makes failure visible at the call site, you can't accidentally forget that it might break.

Wrap calls in `do { … } catch { … }`. If a `try` throws, execution jumps straight to `catch` with the error, skipping the rest of the `do`. You can match specific error cases to react differently.

Two handy variants: `try?` turns a failure into nil (gives you an optional), and `try!` says 'I promise this won't fail' (crashes if it does, use rarely).

Picture it
do {
    try riskyStep()   ──fails──┐
    nextStep()        (skipped)│
} catch {                      │
    handle(error)   ◄──────────┘   jump straight here
}

try?  ► returns nil on failure        try!  ► crash on failure
A thrown error jumps out of the do-block into catch.
Swift
enum LoginError: Error { case empty, tooShort }

func validate(_ pw: String) throws -> String {
    if pw.isEmpty { throw LoginError.empty }
    if pw.count < 6 { throw LoginError.tooShort }
    return pw
}

do {
    let ok = try validate("123")
    print("welcome \(ok)")
} catch LoginError.tooShort {
    print("password too short")
} catch {
    print("other error: \(error)")
}

let maybe = try? validate("hello123")   // String? (nil if it threw)
throws marks failure; try calls it; catch handles it.
Words to know
throws.
Marks a function that can fail by throwing an error.
try.
Required keyword when calling a throwing function.
catch.
Block that runs when a try throws, receiving the error.
try? / try!.
Convert failure to nil / assert it won't fail (crashes if it does).
Remember this
  • Errors are explicit, `try` makes 'this might fail' visible.
  • `do/catch` handles failures; matching cases lets you react specifically.
  • `try?` → optional on failure; `try!` → crash on failure (use sparingly).
3
Stage 3 of 5

Under the hood

How your app manages memory, threads, and data, where bugs hide.

0/4 done

devops

Memory: ARC & Retain Cycles (weak / unowned)

How Swift frees memory automatically, and the one trap that leaks it.

Think of it like…

Every object has a guest counter at the door. Each strong reference is a guest inside. When the count hits zero, the room is cleaned (memory freed). A retain cycle is two friends each refusing to leave until the other does, so the room is never empty and never cleaned. `weak` is a guest who doesn't count toward the total, breaking the standoff.

Swift uses ARC (Automatic Reference Counting). Each class instance counts how many strong references point to it. When that count drops to 0, the object is deallocated. You don't write free()/delete, ARC does it.

The classic leak: object A strongly holds B, and B strongly holds A. Neither count ever reaches 0, so neither is freed, a retain cycle. Common in delegates and closures that capture `self`.

Fix it by making one side `weak` (becomes nil automatically when the other goes away, always optional) or `unowned` (assumes the other outlives it, not optional, crashes if wrong). In closures use a capture list: `{ [weak self] in … }`.

Picture it
Healthy:  A ──strong──► B        B count 1 ─► drops to 0 ─► freed ✅

Cycle:    A ──strong──► B
          A ◄──strong── B         both counts stuck at 1 ─► leak ❌

Fix:      A ──strong──► B
          A ◄── weak ── B         weak doesn't bump count ─► freed ✅
weak breaks the standoff so the count can reach zero.
Swift
class Person {
    let name: String
    var card: Card?
    init(name: String) { self.name = name }
    deinit { print("\(name) freed") }
}
class Card {
    weak var owner: Person?     // weak breaks the cycle
}

var p: Person? = Person(name: "Ali")
p?.card = Card()
p?.card?.owner = p
p = nil      // prints "Ali freed", no leak

// In closures, capture self weakly:
// download { [weak self] data in self?.update(data) }
Mark the back-reference weak; use [weak self] in closures.
Words to know
ARC.
Automatic Reference Counting, frees an object when its strong count hits 0.
Strong reference.
A normal reference that keeps an object alive (counts).
Retain cycle.
Two objects strongly holding each other so neither is freed.
weak.
A non-counting reference that auto-nils when the target is freed (optional).
unowned.
Like weak but non-optional; assumes the target outlives it.
Remember this
  • ARC frees objects automatically when no strong references remain.
  • Two objects holding each other strongly = retain cycle = leak.
  • Break it with `weak` (optional, auto-nil) or `unowned`; use `[weak self]` in closures.
ai

Concurrency (async / await)

Do slow work (network, disk) without freezing the screen.

Think of it like…

At a coffee shop you order, get a buzzer, and sit down, you don't stand frozen at the counter blocking everyone. `await` is the buzzer: 'wake me when it's ready'. While you wait, the app stays responsive and can do other things. When the buzzer rings, your code continues right where it left off.

Slow tasks (downloading, reading files) must NOT run on the main thread, or the UI freezes. Old code used nested completion-handler closures ('callback hell'). `async/await` makes the same thing read top-to-bottom like normal code.

Mark a slow function `async`. Call it with `await`, which pauses your function (without blocking the thread) until the result is ready, then resumes. You start async work from a `Task { }`.

To prevent data races (two tasks touching the same data at once), Swift has `actor` types, only one task touches an actor's state at a time. UI updates must happen on `@MainActor`.

Picture it
Without async (blocks):
  [download.................] then UI  ← screen frozen 😖

With async/await:
  start ─► await download ···(buzzer)···► resume ─► update UI
            │ thread is free to do other work meanwhile │ 🙂
await suspends your code, not the whole app.
Swift
func fetchUser() async throws -> String {
    let url = URL(string: "https://example.com/me")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return String(decoding: data, as: UTF8.self)
}

Task {                              // start async work
    do {
        let user = try await fetchUser()
        await MainActor.run { label.text = user }   // UI on main
    } catch { print(error) }
}

actor Counter {                     // safe shared state
    private var n = 0
    func bump() { n += 1 }
}
async marks slow work; await waits without freezing; actors guard shared state.
Words to know
async.
Marks a function that can suspend and resume (does slow work).
await.
Pause until an async result is ready, without blocking the thread.
Task.
A unit of async work you kick off from regular code.
actor.
A type that serialises access to its state to prevent data races.
@MainActor.
Marks code that must run on the main thread (all UI updates).
Remember this
  • Never block the main thread, use async/await for network/disk work.
  • `await` suspends your function but keeps the app responsive.
  • Use `actor` / `@MainActor` to keep shared and UI state safe.
backend

GCD & Threads

Move slow work off the main thread so the screen never freezes.

Think of it like…

Your app is a restaurant with one waiter who must keep serving tables (the main thread draws the UI). If that waiter stops to cook a big meal, every customer waits and the place looks frozen. GCD lets the waiter hand heavy cooking to line cooks in the back (background queues), then return to serving. When the food is ready, it comes back to the waiter to be plated (back to main).

The main thread is the only place UI is allowed to change, and it must stay free to stay smooth (60 to 120 frames a second). Anything slow (downloads, big loops, disk) must run elsewhere.

Grand Central Dispatch (GCD) hands work to queues. `DispatchQueue.global()` runs work in the background; `DispatchQueue.main` runs it back on the main thread for UI. `async` schedules without waiting; `sync` waits (and can deadlock on main, so avoid it there).

Modern Swift's async/await sits on top of this idea, but you still meet GCD across real codebases, and the golden rule never changes: never block main, and always hop back to main for UI.

Picture it
MAIN queue (UI only, keep it free)
   │  hand off heavy work
   ▼
GLOBAL queue (background) ── download / parse ──┐
                                                │ done
   plate the result on screen  ◄── main.async ──┘
Heavy work on a background queue, UI updates back on main.
Swift
DispatchQueue.global(qos: .userInitiated).async {
    let data = heavyDownload()          // background, no UI freeze
    DispatchQueue.main.async {
        self.label.text = data          // back on main for UI
    }
}

// Serial queue = one task at a time (protects shared state)
let queue = DispatchQueue(label: "cache")   // serial by default
global().async for work, main.async to touch UI.
Words to know
Main thread.
The single thread where all UI updates must happen.
GCD.
Grand Central Dispatch: Apple's system for running work on queues.
Serial vs concurrent queue.
One-at-a-time vs many-at-once execution.
QoS.
Quality of Service: a priority hint like .userInitiated or .background.
Remember this
  • Never block the main thread; do slow work on a background queue.
  • Always hop back to `DispatchQueue.main` before touching UI.
  • async/await is the modern layer over this same rule.
backend

Codable (JSON in and out)

Turn Swift values into JSON and back with almost no code.

Think of it like…

Codable is a translator with two directions. Encodable packs your Swift object into a JSON 'suitcase' to send over the network. Decodable unpacks an arriving JSON suitcase back into a Swift object. Mark your type Codable and Swift writes the translator for you.

Conform a type to Codable (which is Encodable & Decodable) and, as long as every property is itself Codable, the compiler generates all the packing/unpacking automatically.

Use JSONEncoder to turn a value into Data (to upload) and JSONDecoder to turn received Data into your type. Decoding throws if the JSON doesn't match, so you handle errors.

If the server's key names differ from your property names, add a CodingKeys enum to map them, no manual parsing needed.

Picture it
Swift value  ──JSONEncoder──►  JSON data   (upload)
   User                            {"name":"Ali"}

JSON data    ──JSONDecoder──►  Swift value  (receive)
{"name":"Ali"}                    User(name: "Ali")
Encode to send, decode to receive. The compiler writes both.
Swift
struct User: Codable {
    let name: String
    let age: Int
    enum CodingKeys: String, CodingKey {
        case name
        case age = "user_age"      // map a different server key
    }
}

let json = #"{"name":"Ali","user_age":8}"#.data(using: .utf8)!
let user = try JSONDecoder().decode(User.self, from: json)
let back = try JSONEncoder().encode(user)   // -> Data
Codable + JSONDecoder/Encoder; CodingKeys maps mismatched names.
Words to know
Codable.
Typealias for Encodable & Decodable; enables JSON conversion both ways.
JSONDecoder / JSONEncoder.
Turn Data into a type / a type into Data.
CodingKeys.
An enum that maps property names to custom JSON keys.
Remember this
  • Mark a type `Codable` and the compiler writes the JSON conversion for you.
  • `JSONDecoder().decode` can throw, so decode inside do/try/catch.
  • Use `CodingKeys` when server key names differ from your properties.
4
Stage 4 of 5

Frameworks & data

Building screens and moving data the way real apps do.

0/5 done

interface

SwiftUI: Views & @State

Describe what the screen should look like for the current data; it redraws itself.

Think of it like…

SwiftUI is like a thermostat display. You don't manually repaint the number every second, you just wire the display to the temperature, and whenever the temperature changes, the display refreshes itself. `@State` is that wiring: change the value, the view re-draws automatically.

SwiftUI is declarative: you write what the UI should be for a given state, not step-by-step instructions to mutate it. When the state changes, SwiftUI recomputes the affected views for you.

`@State` holds a small piece of view-owned data. Mutating it triggers a re-render. `@Binding` is a two-way connection that lets a child view read AND write a parent's state (like a TextField editing a value).

A View is a lightweight struct (value type) that's cheap to recreate. You build UI by composing small views, not by subclassing big controllers like in UIKit.

Picture it
@State var count = 0
        │  change it
        ▼
  ┌──────────────┐   SwiftUI notices ─► re-runs body ─► UI updates
  │  body { ... }│◄──────────────────────────────────────┐
  └──────────────┘                                        │
    Button("tap") { count += 1 } ───────────────────────►─┘
State change → body re-runs → screen reflects new data.
Swift
import SwiftUI

struct CounterView: View {
    @State private var count = 0          // view-owned state

    var body: some View {                 // describe the UI
        VStack {
            Text("Count: \(count)")        // re-renders on change
            Button("Add") { count += 1 }   // mutate → auto update
        }
    }
}

// @Binding: child edits parent's value
struct NameField: View {
    @Binding var name: String
    var body: some View { TextField("Name", text: $name) }
}
@State drives re-rendering; @Binding shares write access with children.
Words to know
Declarative UI.
Describe the result for a state; the framework updates the screen.
View.
A struct describing a piece of UI via its `body`.
@State.
View-owned data; changing it re-renders the view.
@Binding.
A two-way reference so a child can read and write a parent's state.
some View.
An opaque return type meaning 'some specific View I'm not spelling out'.
Remember this
  • Declarative: describe UI for the current state; it redraws on change.
  • `@State` = view's own data; mutating it triggers a re-render.
  • `@Binding` ($value) gives children two-way access to parent state.
hybrid

Auto Layout

Describe how views relate, and the system fits them to any screen.

Think of it like…

Fixed pixel positions are like memorising one exact seating chart, useless the moment you change rooms. Auto Layout is giving directions instead: 'sit 16 points from the left wall, centred, at least 8 points below the title'. Those rules hold in any room, so your layout works on a small iPhone, a big iPhone, and an iPad without rewriting it.

You add constraints: relationships about position and size (this view's leading edge equals that one's, this is centred, this is at least 100 wide). At runtime the engine solves them into exact frames for the current screen and orientation.

When rules could conflict, priorities decide the winner, and content hugging / compression resistance control whether a label stretches or stays snug around its text.

In UIKit you usually use layout anchors (or Interface Builder). In SwiftUI this is mostly automatic: stacks (VStack/HStack), padding, and frame do the relating for you.

Picture it
Fixed frame  ──► breaks on a different screen size ❌

Constraints (relationships, solved at runtime):
 ┌─ screen ───────────────┐   leading = 16
 │        [ Button ]      │   centerX = superview.centerX
 │     top = safe + 20    │   width  ≥ 100
 └────────────────────────┘   ── fits ANY size ✅
Rules about relationships, not pixels, so layouts adapt.
Swift
box.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    box.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    box.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
                             constant: 20),
    box.widthAnchor.constraint(greaterThanOrEqualToConstant: 100),
])
// SwiftUI: VStack { ... }.padding() does the relating automatically.
UIKit uses anchors; SwiftUI stacks/padding handle it for you.
Words to know
Constraint.
A rule about a view's position or size relative to others.
Anchor.
A view edge/dimension you constrain (leadingAnchor, topAnchor, …).
Intrinsic content size.
The natural size a view wants based on its content (e.g. text).
Hugging / compression resistance.
How willing a view is to stretch vs shrink around its content.
Remember this
  • Describe relationships (constraints), not fixed pixel frames.
  • The engine solves them per screen size and orientation.
  • UIKit: anchors/Interface Builder. SwiftUI: stacks, padding, frame.
interface

App Lifecycle

The states your app moves through, and when to save or pause.

Think of it like…

Your app is a shop. It can be open and serving customers (active/foreground), lights-dimmed doing quick closing tasks (background), or fully closed and frozen (suspended). The system rings a bell at each transition so you can save the till, pause the music, and lock up before the lights go out.

An app moves between states: not running, inactive (briefly, e.g. mid-swipe or an incoming call), active (on screen, receiving input), background (off screen, limited time for quick work), and suspended (frozen in memory, may be killed).

The system notifies you on each transition. Use those moments to save state, stop timers, release resources, and finish small tasks, because background time is short and the OS can suspend or terminate you.

In SwiftUI you observe `scenePhase` (.active / .inactive / .background). In UIKit it's the AppDelegate and SceneDelegate callbacks (didFinishLaunching, sceneDidEnterBackground, …).

Picture it
Not running ──► Inactive ──► Active  (foreground, on screen)
                   ▲             │  user leaves
                   └─ Background ◄┘  (brief tasks)
                          │
                          ▼
                      Suspended  (frozen; may be killed)
   leaving Active? ──► save state + pause work
Save and pause when you leave Active; background time is short.
Swift
import SwiftUI

@main
struct MyApp: App {
    @Environment(\.scenePhase) private var phase
    var body: some Scene {
        WindowGroup { ContentView() }
            .onChange(of: phase) { _, newPhase in
                switch newPhase {
                case .active:     resume()       // back on screen
                case .inactive:   pauseLightly()  // transient
                case .background: saveState()      // may suspend next
                @unknown default: break
                }
            }
    }
}
SwiftUI: react to scenePhase; UIKit: AppDelegate/SceneDelegate callbacks.
Words to know
Active.
Foreground and receiving events; the normal in-use state.
Inactive.
Foreground but not receiving events (transient, e.g. a call arrives).
Background.
Off screen with limited time for quick work before suspension.
Suspended.
Frozen in memory, doing nothing; the system may terminate it.
scenePhase.
SwiftUI's environment value reporting the current lifecycle phase.
Remember this
  • States: not running → inactive → active → background → suspended.
  • Save state and pause work when leaving active; background time is short.
  • SwiftUI: observe `scenePhase`. UIKit: AppDelegate/SceneDelegate callbacks.
devops

Combine (reactive streams)

Values that flow over time through a pipe of transformations.

Think of it like…

Combine is plumbing for values. A publisher is a tap that emits values over time, subscribers are the ones drinking at the end, and operators are filters fitted along the pipe (clean the water, change it, slow it down). When the tap emits, the value flows through every filter to whoever is listening.

A Publisher sends a stream of values (then a completion or failure). A Subscriber receives them. In between, operators like map, filter, and debounce transform the stream, the same mindset as map/filter on arrays, but over time instead of over a list.

You keep a subscription alive by storing the returned AnyCancellable; when it's released, the stream stops. `@Published` properties are publishers, which is how view models broadcast change.

Swift's async/await and AsyncSequence now cover many cases Combine used to own, but Combine is still everywhere in existing iOS code, especially around `@Published` and UI events.

Picture it
Publisher ──► [map] ──► [filter] ──► Subscriber (sink)
 emits 1,2,3     ×2        keep even     receives 4,6
            1,2,3 ─► 2,4,6 ─► 4,6 ─────► drinks them
Values flow through operators to a subscriber, over time.
Swift
import Combine

final class SearchVM: ObservableObject {
    @Published var query = ""             // a publisher
    @Published var results: [String] = []
    private var bag = Set<AnyCancellable>()

    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .map { $0.lowercased() }
            .sink { [weak self] q in self?.search(q) }
            .store(in: &bag)              // keep the subscription alive
    }
    func search(_ q: String) { /* ... */ }
}
$query is a publisher; operators shape the stream; sink subscribes.
Words to know
Publisher.
A source that emits values over time (e.g. a @Published property).
Subscriber.
Receives the emitted values, e.g. via sink.
Operator.
A transform in the pipeline: map, filter, debounce, etc.
AnyCancellable.
A token that keeps a subscription alive until it's released.
Remember this
  • Combine = map/filter thinking, but over values that arrive over time.
  • Store the AnyCancellable or the subscription stops immediately.
  • `@Published` is a publisher, great for reactive view models.
backend

Networking (URLSession + an API layer)

Ask a server for data, get it back, decode it, handle failure.

Think of it like…

Calling a server is like phoning a restaurant for delivery. You place an order (a request to a URL), wait, and either get your food (a response with data) or bad news (an error, or an 'out of stock' status code). Smart apps put a 'waiter' layer in between so the rest of the app just says 'get me the menu', without knowing how the call is made.

URLSession makes the actual call. With async/await you write `try await URLSession.shared.data(from: url)`, then decode the JSON into your Codable model. Always check the HTTP status code, not just that some data came back.

Wrap this in an API client behind a protocol. The rest of the app depends on the protocol, so you can inject a fake in tests (see dependency injection) and never hit the real network in a unit test.

Handle the unhappy paths: no connection, timeouts, non-200 status, and JSON that doesn't match. Surfacing these as typed errors keeps the UI honest.

Picture it
App ──► APIClient ──► URLSession ──► Server
        (protocol)         │  data + status
 model  ◄── decode JSON ◄──┘
   app  ──► LiveAPIClient
   test ──► FakeAPIClient   (no network)
A protocol-based client layer keeps calls swappable and testable.
Swift
protocol APIClient {
    func fetchUser() async throws -> User
}

struct LiveAPIClient: APIClient {
    func fetchUser() async throws -> User {
        let url = URL(string: "https://api.example.com/me")!
        let (data, resp) = try await URLSession.shared.data(from: url)
        guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode(User.self, from: data)
    }
}
async URLSession + Codable, behind a protocol you can fake in tests.
Words to know
URLSession.
Apple's API for making network requests.
HTTP status code.
A number like 200 (ok) or 404 (not found) describing the result.
API client layer.
A protocol-backed type that hides networking from the rest of the app.
Decoding.
Turning the returned JSON Data into a Swift model via Codable.
Remember this
  • Use async URLSession; decode JSON with Codable; check the status code.
  • Hide networking behind a protocol so it's swappable and testable.
  • Handle the unhappy paths: offline, timeout, bad status, bad JSON.
5
Stage 5 of 5

Senior craft

Architecture, patterns, and habits that scale a codebase and a team.

0/8 done

devops

Architecture: MVVM

Separate your screen, your logic, and your data so each stays testable.

Think of it like…

Think of a restaurant. The Model is the kitchen + pantry (the real data). The View is the dining room (what guests see). The ViewModel is the waiter: it takes the kitchen's raw food, plates it nicely for the table, and carries orders back. The dining room never barges into the kitchen, it talks to the waiter.

MVVM splits responsibilities. Model = your data and business rules. View = the UI (SwiftUI views or UIKit screens). ViewModel = the 'translator' that prepares data for display and handles user actions.

The View stays dumb: it just shows what the ViewModel exposes and forwards taps. The ViewModel holds the presentation logic (formatting, loading, validation) and has no UI code, so you can unit-test it without launching the app.

In SwiftUI the ViewModel is usually an `ObservableObject` with `@Published` properties; the View observes it with `@StateObject`/`@ObservedObject` and re-renders when published values change.

Picture it
   VIEW  ◄──observes──  VIEW MODEL  ◄──reads/writes──  MODEL
 (dining room)          (the waiter)              (kitchen+pantry)
     │  user taps           │  formats data            │ raw data,
     └──actions────────────►│  loads, validates        │ network, db
                            └─────────────────────────►│
View talks only to the ViewModel; the ViewModel owns the logic.
Swift
import SwiftUI

@MainActor
final class ProfileVM: ObservableObject {
    @Published var title = "Loading…"      // View watches this

    func load() async {
        // ...fetch from Model layer...
        title = "Hello, Ali"               // change → View updates
    }
}

struct ProfileView: View {
    @StateObject private var vm = ProfileVM()
    var body: some View {
        Text(vm.title)
            .task { await vm.load() }       // ask the VM to work
    }
}
ViewModel = ObservableObject with @Published; View observes and reacts.
Words to know
Model.
The raw data and business rules (often network/database).
View.
The UI layer; shows state and forwards user actions.
ViewModel.
Presentation logic that prepares data for the View; UI-free and testable.
ObservableObject / @Published.
SwiftUI plumbing so the View re-renders when VM data changes.
Remember this
  • Model (data) · View (UI) · ViewModel (presentation logic), clear separation.
  • Keep the View dumb; put logic in the ViewModel so it's unit-testable.
  • In SwiftUI: ViewModel = ObservableObject + @Published, View observes it.
devops

Clean Architecture

Keep business rules in the centre, frameworks at the edges.

Think of it like…

Think of an onion. The core (your business rules) sits protected in the middle and knows nothing about the outside world. The outer layers (UI, database, network) can be peeled off and replaced without touching the core. The rule: outer layers depend on the inside, never the other way round, like a company whose mission doesn't depend on which email app it uses.

Code is split into layers: Entities (core data + rules), Use Cases (what the app does), and outer adapters (UI, network, storage). The Dependency Rule says source-code dependencies point inward only; the core never imports UIKit or a database.

Because the core depends only on protocols (not concrete frameworks), you can swap SwiftUI for UIKit, or a real database for an in-memory fake, without rewriting business logic. That's what makes large apps maintainable and testable.

You don't need the full ceremony on every screen, but the instinct, push logic inward and keep frameworks at the edges, is exactly what senior reviewers look for.

Picture it
┌──────────────────────────────┐
│  Frameworks (UI · DB · Net)  │  outer
│  ┌────────────────────────┐  │
│  │  Use Cases (actions)   │  │
│  │  ┌──────────────────┐  │  │
│  │  │ Entities (core)  │  │  │  inner
│  │  └──────────────────┘  │  │
│  └────────────────────────┘  │
└──────────────────────────────┘
 dependencies point INWARD only ──►
Inner layers know nothing about the outer ones.
Swift
// Core defines a protocol; it imports no UIKit, no database.
protocol UserRepository {                 // a boundary
    func load(id: Int) async throws -> User
}

struct FetchUser {                        // a use case
    let repo: UserRepository
    func run(id: Int) async throws -> User {
        try await repo.load(id: id)
    }
}
// The real repo (network/db) lives in the OUTER layer and conforms.
The core defines protocols; outer layers implement them.
Words to know
Entity.
Core data and business rules, framework-free.
Use case.
A single thing the app does, orchestrating entities.
Dependency Rule.
Source dependencies point inward; the core imports nothing outer.
Boundary.
A protocol that separates a layer from the ones outside it.
Remember this
  • Business rules in the centre; UI/DB/network at the edges.
  • Dependencies point inward only, expressed through protocols.
  • Swap frameworks and fakes without touching core logic.
hybrid

Design Patterns (Delegate, Singleton, Observer)

Named, reusable solutions to problems iOS devs hit again and again.

Think of it like…

Patterns are recipes everyone already knows. Delegation is a teacher asking the class monitor to handle a task on their behalf. A Singleton is the school's one and only principal: there's exactly one, and everyone reaches the same person. Observer is subscribing to a channel: when something happens, all subscribers get notified at once.

Delegation (the most common iOS pattern): one object hands off responsibility to another via a protocol, like a table view asking its delegate 'what do I do when a row is tapped?'. It's one-to-one and protocol-based, and the delegate is held `weak` to avoid a retain cycle.

Singleton: a single shared instance reachable everywhere via `.shared`. Handy for a logger, but overuse creates hidden global state that's hard to test, so use it sparingly and prefer injection.

Observer: one event, many listeners. NotificationCenter (and Combine's publishers) broadcast a change to anyone subscribed, decoupling the sender from the receivers.

Picture it
Delegate (1:1)   Cell ──asks──► delegate?.didTap()
Singleton (1)    Logger.shared  ← the only instance, everywhere
Observer (1:N)   event ──► [ listenerA · listenerB · listenerC ]
Delegate hands off; singleton is one shared; observer notifies many.
Swift
// Delegation
protocol RowTapHandler: AnyObject { func didTap(_ row: Int) }
final class List {
    weak var delegate: RowTapHandler?      // weak: avoid a cycle
    func tap(_ r: Int) { delegate?.didTap(r) }
}

// Singleton (use sparingly)
final class Logger { static let shared = Logger(); private init() {} }

// Observer
NotificationCenter.default.post(name: .init("didLogin"), object: nil)
Delegate via a weak protocol; singleton via static let; observer via NotificationCenter.
Words to know
Delegation.
Handing a task to another object through a protocol (1:1).
Singleton.
A single shared instance accessed via .shared.
Observer.
Broadcasting an event to many subscribers (1:N).
weak delegate.
Delegates are held weakly to avoid retain cycles.
Remember this
  • Delegation is the workhorse iOS pattern; keep the delegate `weak`.
  • Singletons are convenient but become hidden global state, use sparingly.
  • Observer (NotificationCenter/Combine) decouples one sender from many listeners.
devops

SOLID Principles

Five habits that keep code easy to change instead of fragile.

Think of it like…

SOLID is five rules for building with LEGO so the model doesn't collapse when you change it: each brick has one job, you extend by adding bricks rather than re-cutting old ones, any same-shaped brick can stand in for another, you offer small specific bricks instead of one giant do-everything piece, and you build to a standard stud pattern rather than gluing to one exact brick.

S, Single Responsibility: a type should have one reason to change. O, Open/Closed: open to extension, closed to modification (add behaviour without editing tested code). L, Liskov Substitution: a subtype must work anywhere its parent is expected.

I, Interface Segregation: many small focused protocols beat one fat protocol that forces types to implement things they don't need. D, Dependency Inversion: depend on abstractions (protocols), not concrete types, which is exactly what dependency injection enables.

You won't recite these in daily work, but they explain WHY clean code feels clean, and senior reviewers spot violations (a 600-line view controller doing five jobs breaks the S).

Picture it
S   one job per type
O   extend, don't modify
L   a subtype fits where its parent fits
I   small focused protocols  >  one fat one
D   depend on protocols, not concretes
Five letters, five habits for change-friendly code.
Swift
// Bad: one fat protocol forces unused methods (breaks I + S)
protocol Worker { func code(); func test(); func deploy() }

// Better: small protocols, depend on the abstraction (I + D)
protocol Coder  { func code() }
protocol Tester { func test() }

struct CI { let coder: Coder }   // depends on a protocol, not a class
Split fat protocols; depend on abstractions.
Words to know
Single Responsibility.
One reason to change per type.
Open/Closed.
Extend behaviour without modifying existing code.
Liskov Substitution.
Subtypes must be usable wherever the base type is.
Interface Segregation.
Prefer small, specific protocols over one large one.
Dependency Inversion.
Depend on protocols, not concrete implementations.
Remember this
  • Each type one job; extend without editing tested code.
  • Small focused protocols; subtypes must be safe stand-ins.
  • Depend on abstractions, the foundation of testable design.
devops

Modularization (Swift Package Manager)

Split one giant app into smaller packages that build and test on their own.

Think of it like…

Building one massive app target is like gluing every LEGO set into a single block: change one piece and you re-glue (rebuild) everything, and you can't reuse a part. Modularization keeps each set separate: you build and test 'NetworkingKit' on its own, reuse it elsewhere, and a change there doesn't shake the whole tower. Builds get faster too.

Swift Package Manager (SPM) lets you split code into packages/modules (CoreKit, NetworkingKit, a feature module). Each has clear boundaries, and only what it marks `public` is visible outside, which enforces separation.

Benefits: faster incremental builds (only changed modules recompile), enforced architecture (a feature can't secretly reach into another's internals), easier testing, and reuse across apps.

It's a hallmark of senior and lead work: as a team grows, modular boundaries are what keep the codebase and the build time from collapsing under their own weight.

Picture it
Monolith                  Modular (SPM)
┌───────────────┐         App
│  everything   │          ├─ FeatureLogin
│  one target   │          ├─ FeatureFeed
│  slow rebuild │          ├─ NetworkingKit  ← build/test alone
└───────────────┘          └─ CoreKit         reused everywhere
Separate packages: faster builds, clear boundaries, reuse.
Swift
// Package.swift (a NetworkingKit module)
let package = Package(
    name: "NetworkingKit",
    products: [.library(name: "NetworkingKit", targets: ["NetworkingKit"])],
    targets: [
        .target(name: "NetworkingKit"),
        .testTarget(name: "NetworkingKitTests", dependencies: ["NetworkingKit"]),
    ]
)
// Only 'public' symbols are visible to the app that imports it.
Each module is a Swift package with its own target and tests.
Words to know
Module.
A unit of code with its own namespace and boundary.
Swift Package (SPM).
Apple's dependency and module manager for splitting code.
public.
The access level that exposes a symbol outside its module.
Incremental build.
Recompiling only what changed, faster with modules.
Remember this
  • Split the app into packages with clear, enforced boundaries.
  • Faster builds (only changed modules recompile) and real reuse.
  • `public` controls what each module exposes; everything else stays internal.
devops

Dependency Injection

Hand a type its tools from outside instead of letting it build its own.

Think of it like…

Imagine a chef who insists on growing their own vegetables: you can never give them frozen veg to test a recipe quickly. Dependency injection means you deliver the ingredients to the chef instead. Now in tests you can hand over fake ingredients and check the cooking without a real farm (real network).

A dependency is anything an object needs to do its job (a network client, a database, a clock). Injection means passing those in (via initialiser or property) rather than creating them inside.

Depend on a protocol, not a concrete type. Then you can pass the real implementation in the app and a fake/mock one in tests, swapping behaviour without touching the class.

This is the backbone of testable, modular code, and a big part of what separates senior-level architecture from tightly-coupled code.

Picture it
Tight coupling:   ViewModel ──makes──► RealNetwork   (can't test)

Injected:         protocol API { func load() async -> Data }
                  ViewModel(api: API)
                       app  ──► RealAPI
                       test ──► FakeAPI   ← swap freely
Pass tools in; depend on a protocol so they're swappable.
Swift
protocol Weather {                       // the contract
    func today() async -> String
}
struct RealWeather: Weather {
    func today() async -> String { "sunny" }   // real network
}
struct FakeWeather: Weather {
    func today() async -> String { "test-data" } // for tests
}

final class ForecastVM {
    let api: Weather
    init(api: Weather) { self.api = api }    // injected
}

let appVM  = ForecastVM(api: RealWeather())
let testVM = ForecastVM(api: FakeWeather())  // swap in a fake
Inject a protocol; pass real in the app, fake in tests.
Words to know
Dependency.
Anything a type needs to do its work (network, database, clock).
Injection.
Passing dependencies in (init/property) instead of creating them internally.
Mock / Fake.
A stand-in implementation used in tests instead of the real thing.
Remember this
  • Pass dependencies in; don't let a type secretly build its own.
  • Depend on a protocol so real and fake implementations are interchangeable.
  • It's what makes code unit-testable and loosely coupled, a senior habit.
devops

Performance & Instruments

Measure first, then fix the real bottleneck, never guess.

Think of it like…

Optimising by guessing is like taking random medicine and hoping you feel better. Instruments is the doctor's monitor: it shows your app's heartbeat, where CPU time goes, and where memory leaks. You diagnose first with the monitor, then treat the actual problem.

The cardinal rule: measure, don't guess. Apple's Instruments app profiles a running build. Time Profiler shows where CPU time is spent, Allocations and Leaks show memory growth and leaks, and you can spot main-thread hangs that make scrolling stutter.

Common wins: keep heavy work off the main thread, avoid expensive work in tight loops or cell reuse, cache results, load images lazily, and reuse views. Fix the biggest measured cost first, not the easiest.

Senior engineers tie this to crash-free and smoothness metrics: a 99.8% crash-free app and 60/120fps scrolling come from measuring and removing the real hot spots.

Picture it
MEASURE ──► FIND HOTSPOT ──► FIX ──► MEASURE AGAIN
Instruments:
  Time Profiler  ─► where CPU time goes
  Allocations    ─► memory growth
  Leaks          ─► objects never freed
  Main-thread    ─► hitches / frozen scroll
Profile, fix the biggest real cost, then re-measure.
Swift
// Quick timing in code
let start = CFAbsoluteTimeGetCurrent()
expensiveWork()
print("took \(CFAbsoluteTimeGetCurrent() - start)s")

// Keep the main thread free; cache instead of recomputing
DispatchQueue.global().async {
    let result = process(big)            // off main
    DispatchQueue.main.async { self.show(result) }
}
Time work, move heavy work off main, cache repeated results.
Words to know
Instruments.
Apple's profiling app for CPU, memory, and timing.
Time Profiler.
Shows where CPU time is spent in your code.
Allocations / Leaks.
Track memory growth and objects that are never freed.
Main-thread hitch.
A frozen frame caused by slow work on the main thread.
Remember this
  • Measure with Instruments before optimising; fix the biggest real cost.
  • Keep heavy work off the main thread; cache and reuse.
  • Smoothness and crash-free rates come from removing measured hot spots.
devops

Unit Testing (XCTest)

Small automated checks that prove your code does what you think.

Think of it like…

A unit test is a smoke detector for one room of your app. You set up a situation, run the code, and assert the result is what you expect. If someone later changes the wiring and smoke appears, the detector goes off immediately, before your users smell anything.

A unit test calls a small piece of your code with known input and asserts the output. The pattern is Arrange (set up), Act (run it), Assert (check it).

XCTest is Apple's built-in framework: test methods start with 'test', and you check results with XCTAssertEqual, XCTAssertTrue, XCTAssertNil, and friends.

Tests pay off most when code is injectable (see dependency injection): pass in fakes so a test runs instantly without real network or database. Async code is tested with async test methods and await.

Picture it
Arrange ─► Act ─► Assert
  set up    run     check expected == actual

func test_add() {
    let r = Calculator().add(2, 3)   // Act
    XCTAssertEqual(r, 5)             // Assert ✅ / ❌
}
Every test: arrange the inputs, act, assert the result.
Swift
import XCTest
@testable import MyApp

final class CalculatorTests: XCTestCase {
    func test_add_returnsSum() {
        let calc = Calculator()          // Arrange
        let result = calc.add(2, 3)      // Act
        XCTAssertEqual(result, 5)        // Assert
    }

    func test_fetch_usesFake() async {
        let vm = ForecastVM(api: FakeWeather())   // injected fake
        let text = await vm.api.today()
        XCTAssertEqual(text, "test-data")
    }
}
test* methods + XCTAssert*; inject fakes to avoid real I/O.
Words to know
Unit test.
An automated check of one small piece of code in isolation.
Assertion.
A check that a value matches expectation (XCTAssertEqual, etc.).
Arrange-Act-Assert.
The three-step shape of a clear test.
@testable import.
Gives tests access to your module's internal types.
Remember this
  • Tests follow Arrange, Act, Assert and catch regressions early.
  • XCTest: methods named `test…` with `XCTAssert…` checks.
  • Inject fakes so tests run fast and deterministically, no real network.
Checkpoint · Swift

Test yourself.

Pick an answer to see if you got it, with a one-line why. Finish the set for a score, then start a fresh, reshuffled round from the 160-question bank.

Loading questions…