App architecture · expert

A reference architecture: all of Ledger on one page

The capstone. The whole money app assembled in one place — the module map, one request end to end, single-flight token refresh, offline writes, the UIKit seam, and what gets tested where. This is the lesson to reread before a system-design interview.

20 min read16 min practice0/3 exercises6 recall cards

By the end you will be able to

  • Draw a medium app's module map and defend every arrow on it
  • Trace one request end to end through cache-then-refresh
  • Build a single-flight token refresh on an actor, and explain the actor reentrancy trap it avoids
  • Place auth, offline writes, background work and the UIKit seam without breaking the dependency rule
  • Map the testing pyramid onto the layers

"Design the iOS app for a mid-size bank. There is the whiteboard."

Senior interviews ask some version of that question, and they ask it because you cannot revise for it the night before. The answer only sounds convincing if you have built the pieces yourself.

You have built them. Over the last two lessons, Ledger — the money-movement app that runs through this whole unit — grew a screen architecture (D1-04) and a domain core (D1-05). Across the D2, C and P tracks you built networking, caching, offline sync, keychain storage, actors and test harnesses. This lesson assembles all of that into one answer, and into one drawing you can reproduce from memory.

Five subsystems, one page: the module map, one read, the session, one write, and the place where UIKit still lives.

Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

The app comes to the foreground. The Feed screen and the Insights screen each fire their own request; the access token expired overnight, so both get a 401. What must the architecture guarantee next?

The map

Ledger — the whole mapLedgerAppapp targetBalanceWidgetwidget targetLedgerFeaturesscreens · VMs · routesLedgerDatarepos · DTOs · vault · outboxLedgerCoreentities · use cases · portstwo products, one truth — every target runs the same core rules
Three packages and two targets. Every arrow is one module importing another, and LedgerCore has no outgoing arrow at all.

Read the arrows top to bottom. Each one is an import statement that the build system either permits or refuses:

  1. The app target imports LedgerFeatures (screens, view models, routes) and LedgerData (repositories, wire DTOs, the token vault, the outbox). A DTO is a data transfer object: a struct shaped exactly like the JSON the server sends, which D1-05 built for Ledger.
  2. The widget target imports LedgerData, and gets LedgerCore along with it, because LedgerData imports the core. So the widget can read the cached balance and call Money.formatted. That is all it can do.
  3. LedgerFeatures and LedgerData both import LedgerCore. Neither imports the other. A screen reaches the network by calling a protocol the core declares, never by naming a type that lives in LedgerData.
  4. LedgerCore imports nothing. Its Package.swift says dependencies: [], so writing import SwiftUI inside it produces a build error rather than a code-review argument. D1-05 set that up.

This is D1-05's package graph with one thing added: the widget target. That addition is where the map earns its keep. "Can the widget show the send-money button?" is not a question anyone has to hold a meeting about. SendMoneyView lives in LedgerFeatures, the widget target does not link LedgerFeatures, so the answer is a compiler error.

Every subsystem on this page has a full lesson behind it. This table is the curriculum, assembled into one app:

SubsystemLives inDeep lesson
Entities, use cases, policiesLedgerCoreD1-05
Screen state, VMs, routesLedgerFeaturesD1-04
URLSession client, status and error handlingLedgerDataD2-01 networking
Wire DTOs and the mapping borderLedgerDataD1-05
Cache (memory + disk)LedgerDataD2-03 caching
Outbox for offline writesLedgerDataD2-04 offline sync
Token bytes, biometric gateLedgerData → KeychainD2-05 keychain
TokenVault single-flightLedgerData (actor)C2-01 actors
Background refreshApp target registers, LedgerData executesP3-03 lifecycle
Tests at every layereach package's test targetP1-01 testing

One tap, end to end

Opening the feed is the path the user takes most often, so it is the one worth getting right. It has to answer a question the textbook version skips: what is on the screen while the network request is still in flight?

For a money app the answer is: yesterday's numbers immediately, then today's when they arrive. A spinner over a blank screen is the wrong answer any time a cached copy exists.

One tap, end to endScreenViewModelRepositoryCache · API1 · .task → load()2 · fetchFeed()3 · cache.read()4 · paint #1 — cached, instant5 · GET /transactions6 · DTOs → map → cache.replace7 · paint #2 — fresh
One load, two paints. The cache answers first and the network replaces it. D2-03 calls this pattern stale-while-revalidate.

A paint here means one moment when the screen redraws with new data. This load produces two of them. Read the diagram top to bottom — the numbers are the order of events:

  1. The screen appears and .task calls load(). .task is SwiftUI's modifier for starting async work when a view appears; load() is the view model's method. This is D1-04's wiring, unchanged.
  2. The view model calls the repository. It calls fetchFeed(), which is declared on a port — a protocol that LedgerCore owns and LedgerData conforms to. The view model therefore has no idea whether rows come from memory, from disk or from the network.
  3. The repository reads the cache first. The cache is an actor holding an in-memory copy, so this is a local lookup that returns in microseconds. No network call has started yet.
  4. First paint. The view model sets its state to .loaded(cached), one case of D1-04's LoadState enum. The user is reading yesterday's rows before the network has finished connecting.
  5. Now the network request goes out. The user already has something to read, so nobody is waiting on it.
  6. The response crosses the border. The wire DTOs are converted into domain types by D1-05's mapper, and the cache is replaced with the result. The next cold launch will now start from today's numbers.
  7. Second paint. .loaded(fresh) swaps the new rows in.
Loading runnable Swift…

Connect that to D1-04's LoadState and the screen needs no extra work: .loaded(cached) first, .loaded(fresh) when the response lands.

Now consider what happens with no signal, because it needs no new code at all. The transport throws. The repository turns that transport error into a domain error, which is D1-05's border rule. The screen keeps the cached rows exactly where they are and adds a quiet line saying "updated 8:02". The user is not shown an error wall over data they can already see. Offline reading is not a feature you add later. It is what a cache-first read path already does when the network fails.

The session: two 401s, one refresh

Now the thing the pretest promised. Token refresh sounds like three lines of code: if the server returns 401, fetch a new access token, retry the request. It stays that simple until two screens get a 401 in the same instant.

The next block is a predict rather than an explanation, because the failure is the lesson. Read the code, decide what it prints, then check.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

NaiveVault checks whether the token is still stale before it refreshes, which looks like it should prevent a second refresh. Both callers arrive holding the same expired token, "v1". What does this print?

actor NaiveVault {
    var token = "v1"
    var refreshes = 0

    func refreshIfStale(_ stale: String) async -> String {
        if stale != token { return token }   // someone already fixed it?
        refreshes += 1
        await Task.yield()                   // the network call: a suspension point
        token = "v2"
        return token
    }
}

func main() async {
    let vault = NaiveVault()
    async let a = vault.refreshIfStale("v1")
    async let b = vault.refreshIfStale("v1")
    let results = await [a, b]
    print(results)
    print("refreshes:", await vault.refreshes)
}
await main()

No smarter check fixes this, because any check can be overtaken at the next await. The fix is to stop the second caller doing its own work, and make both callers share one piece of work instead.

That pattern has a name: single-flight. Many callers ask for the same thing at the same time, exactly one request is made, and everyone receives that one request's result. In Swift the shape is to keep the running refresh on the actor as a stored Task. The first caller creates it. Anyone arriving while it is stored awaits that same handle.

Loading runnable Swift…

Three lines in that code carry the pattern:

  • return await running.value is the join. Any number of callers can await one Task's value, and they all receive the same result.
  • The first caller stores inFlight = work before it awaits anything. Creating a Task is not a suspension point, so no second caller can slip in between those two lines.
  • inFlight = nil runs once the refresh finishes. Without it, tomorrow morning's genuine expiry would await a task that completed yesterday and get yesterday's token back.
Two 401s, one refreshFeed req401Insights req401TokenVaultactor · single-flightONE refreshauth APIKeychaintoken byteslate callers await the same in-flight Task,then every request retries with the fresh token
Where the vault sits. Both 401s converge on one actor, the keychain stores the token bytes, and the session state above decides signed in or signed out.

Read the diagram left to right:

  1. Two requests fail with a 401 at the same moment. Neither of them refreshes anything itself.
  2. Both call the same TokenVault actor. One refresh leaves the vault for the auth server — that is the arrow labelled "ONE refresh".
  3. The new token bytes go into the Keychain, which is the only place they are ever stored.
  4. Both requests retry with the fresh token. Nothing above the vault ever learned that a refresh happened.

Now place the three pieces, checking each against the dependency rule:

  • TokenVault is an actor in LedgerData, behind a port the core owns — call it TokenProviding. LedgerCore knows something can supply a token. It does not know that thing is an actor, or that an auth server exists.
  • The token bytes live in the Keychain, written with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Two separate decisions hide in that one constant, and D2-05's table covers both. AfterFirstUnlock means the item is readable at any point after the first unlock following a reboot. Background refresh needs exactly that. A WhenUnlocked item cannot be read while the phone is locked in a pocket, which is the classic "sync works in testing, fails overnight" bug. ThisDeviceOnly means the item is never restored onto a different device from an encrypted backup.
  • Session state — signed in, signed out, locked — is an app-level observable that the root view switches on. Signing out is then one assignment. Set the phase, and the whole tree below is rebuilt from that value, so every pushed screen disappears without anyone dismissing anything. This is D1-04's "navigation is state" point, with the state owned high enough that sign-out can reach it.
struct RootView: View {
    @Environment(Session.self) private var session

    var body: some View {
        switch session.phase {
        case .signedOut:        WelcomeFlow()
        case .locked:           BiometricGate()      // fintech: cover on .inactive too (P3-03)
        case .active(let user): MainTabs(user: user)
        }
    }
}

Writes that survive tunnels

A slow read only annoys the user. A write is a promise. Someone taps "Send €200" on a train, in a tunnel, with no signal. Two outcomes are unacceptable: the payment quietly disappearing, and the payment happening twice because the connection dropped while the response was on its way back.

The pattern that handles both is the outbox, and D2-04 builds it end to end. The name is borrowed from an email outbox, and it works the same way. The use case does not send the money. It writes a row describing the send into a local, persistent queue — that queue is the outbox — and writing that row is its success. A separate sync engine owns delivery: it drains the queue whenever the network allows, retrying until the server confirms.

Writes that survive tunnelsSendMoneyuse caseappendOutboxrow + idempotency keysync engine drainsAPIthe user sees "queued" — an honest word — and the key makes every retry safe:the server treats a replayed send as the same send, not a second one
SendMoney promises only that the send is durably queued, which is a promise it can keep with no signal. The idempotency key is what makes every retry safe.

Read it left to right:

  1. SendMoney runs the rules it can run locally — the daily limit, the shape of the payment — and if they pass it appends one row to the outbox. The row holds the amount, the recipient and an idempotency key. Then it returns successfully.
  2. That row is on disk, so it survives the app being killed, the battery running out, and the tunnel.
  3. The sync engine drains the outbox when a network is available, sending each row to the API with its key attached.
  4. The server confirms and the row is removed. Or the server refuses, and the row comes back marked rejected.

Two details carry the whole pattern.

The idempotency key. Idempotent describes an operation you can perform more than once and still get the result of performing it once. The key is how you get that guarantee across a network. When the app writes the outbox row it also generates a UUID and stores it in that row. The app generates it, not the server — that is what "client-side" means here. Every attempt to deliver the row then carries the same key. Here is why that matters:

  1. The sync engine sends the €200 row.
  2. The server receives it and moves the money.
  3. The response is lost on the way back, because the train entered a tunnel.
  4. The app cannot know whether step 2 happened, so it retries. It has to: the alternative is losing payments.
  5. The server recognises a key it has already processed. It does not move the money again, and returns the original result.

Without the key, step 5 sends a second €200. With it, the retry is harmless. That is what "exactly-once effect from at-least-once delivery" means, and it is the sentence worth saying out loud in the interview.

The word on screen is "queued", not "sent". The app cannot honestly say "sent" while the row is still sitting in the outbox. D1-04's state enum models "queued" as easily as any other case, so honesty here costs nothing.

That points at the limit of what a client can promise at all. The final balance check belongs to the server, not to you. An outbox row can come back rejected — not enough money, a block on the recipient — and the feed then has to reconcile that with the rows it is already showing. D2-04's merge rules cover how.

The seam where UIKit still lives

Nothing above this section mentioned SwiftUI or UIKit. That is not an accident, and it is the answer to the question the interviewer asks next: "fine, but we have 200 UIKit screens — how does any of this fit?"

A seam, in Michael Feathers' sense from D1-02, is a place chosen in advance where new code can be joined to old code without editing the old code. This section shows where the seams go in two different apps: one built new in SwiftUI, and one with 200 UIKit screens already shipped.

Two shells, one coregreenfield: SwiftUI-firstSwiftUI screensrouters · pathsAppDelegate adaptorpush · lifecycleRepresentable islandscamera · PDFlegacy: UIKit shellUIKit view controllersthe old floorsUIHostingControllerhosts SwiftUI per featureLedgerCore + LedgerDataidentical in both worldsmigrate at the navigation seam — swap screens one router entry at a time
The same core and data packages under two different shells: a SwiftUI-first app on the left, a legacy UIKit app on the right. Only the top layer differs.

The diagram is one picture of two apps:

  1. On the left, the new app. SwiftUI screens on top, with two small UIKit boxes drawn dashed underneath. They are dashed because they are leaves, not layers: no other code passes through them.
  2. On the right, the legacy app. UIKit view controllers on top, and a UIHostingController box holding the features migrated so far.
  3. Underneath both, one shared box: LedgerCore plus LedgerData, identical in each. That is the claim the whole diagram makes — the UI framework is the replaceable part.

In a new app — a fresh project with no existing screens to carry — UIKit survives in exactly two places, and both are deliberate:

  • An AppDelegate adaptor, for callbacks SwiftUI does not surface. The main one in a bank app is receiving the push notification device token. @UIApplicationDelegateAdaptor connects a UIKit app delegate to a SwiftUI App.
  • UIViewRepresentable islands, for capabilities SwiftUI does not have. A UIViewRepresentable wraps one UIKit view so SwiftUI can place it. In a fintech app these are usually the camera screens: photographing a cheque to deposit it, and capturing identity documents for KYC — "know your customer", the identity checks a bank is legally required to run.

Both are leaves: things at the edge of the tree that nothing else depends on. Neither is a layer that other code has to pass through.

@main
struct LedgerApp: App {
    @UIApplicationDelegateAdaptor(PushDelegate.self) var pushDelegate
    private let deps = AppDependencies.live()

    var body: some Scene {
        WindowGroup { RootView().environment(deps.session) }
    }
}

In the legacy app, migration happens one feature at a time, and the seam to cut at is navigation. The existing router or coordinator — the object that already decides which screen comes next — keeps that job. Nothing about navigation changes. What changes is what a single route returns:

  1. Today, the "send money" route returns a storyboard view controller.
  2. Tomorrow, it returns UIHostingController(rootView: SendMoneyFlow(deps: …)). A UIHostingController is a real view controller whose contents happen to be a SwiftUI view, so the router pushes it exactly as it pushed the old one and never needs to know the difference.
  3. Every other route is untouched. That is one screen, one pull request, shippable this week.

The new SwiftUI flow and the old UIKit account screen that pushes it share the same rules and the same repositories from day one. That works because both of them reach the outside world through LedgerCore's ports, rather than through each other.

Do it in this order: leaf screens first (screens that nothing else is pushed from), then tab roots, and the navigation container itself last of all. The order that fails is taking every screen to 80 per cent, because then nothing is finished and both frameworks are live inside every screen at once.

U5-02 covers the mechanics: hosting a SwiftUI view inside UIKit, wrapping a UIKit view for SwiftUI, and the sizing rules for both. This lesson is where those mechanics become a migration plan you can defend. The claim underneath it is short — when the core is layered properly, the UI framework is a detail, and one set of rules runs under two shells.

Check yourself

You inherit that 200-screen UIKit bank app; the mandate is "new features in SwiftUI, migrate incrementally." Which seam do you cut at?

What gets tested where

The testing pyramid is a shape, and the shape is the point: many small fast tests at the bottom, fewer and slower ones in the middle, and a thin layer of whole-app tests at the top. Drawn that way it looks like a pyramid. Teams that never manage to build one always fail in the same place — the bottom row — and the module map is what makes that bottom row possible.

Each layer tests what it owns. The layer below it is replaced by a fake: a small stand-in type you write yourself, handed in at a port.

LayerWhat you assertHarnessShare
LedgerCorepolicies, invariants, use-case scenariosplain structs + fakes, no simulatormost
LedgerDataDTO mapping, error translation, vault single-flightD2-01's HealthyServer / BrokenServer / LyingServermany
LedgerFeaturesLoadState transitions, route parsingD1-04's ScriptedServer patternsome
App shelllaunch wiring, deep link → screena few UI testsleast

Here is the observation worth making out loud in an interview. Teams with an untestable architecture do not end up with a pyramid. They end up with it upside down: nearly all of their tests are UI tests, which are the slowest and the least reliable kind. It is not that those teams prefer UI tests. It is that nothing below the UI can be created on its own. If a view model cannot be built without a network client, and the network client cannot be built without the app's whole dependency container, then driving the app through its interface is the only way left to test anything.

Loading exercise…
Loading exercise…
Loading exercise…
Design itSaved on this device. Never graded.

You're in the system-design round and the prompt is: "Design the iOS app for a mid-size bank." Write your first ninety seconds as you would actually speak them — the boxes you draw, in order; the two arrows you point at while drawing; and the three failure modes you name before the interviewer asks for them. Then add the one question you would ask them before going deeper.

Checkpoint

You can now:

  • Draw the module map and defend every arrow, including why the widget cannot reach screen code
  • Trace a read end to end and explain both paints
  • Reproduce the reentrancy trap from memory and fix it with a stored-Task single-flight
  • Place auth state, token bytes, outbox writes and background refresh in the right layer
  • Argue the UIKit migration seam and map the testing pyramid onto the layers

This closes Ledger's arc: D1-03 chose the shape, D1-04 built the screen, D1-05 built the domain, and this lesson assembled the app. Next up: a second reference architecture — Tape, a real-time trading app, where the same vocabulary meets the opposite failure modes, and some of Ledger's best patterns turn into bugs.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.