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.
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.
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
Read the arrows top to bottom. Each one is an import statement that the build system either permits or refuses:
- The app target imports
LedgerFeatures(screens, view models, routes) andLedgerData(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. - The widget target imports
LedgerData, and getsLedgerCorealong with it, becauseLedgerDataimports the core. So the widget can read the cached balance and callMoney.formatted. That is all it can do. LedgerFeaturesandLedgerDataboth importLedgerCore. Neither imports the other. A screen reaches the network by calling a protocol the core declares, never by naming a type that lives inLedgerData.LedgerCoreimports nothing. ItsPackage.swiftsaysdependencies: [], so writingimport SwiftUIinside 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:
| Subsystem | Lives in | Deep lesson |
|---|---|---|
| Entities, use cases, policies | LedgerCore | D1-05 |
| Screen state, VMs, routes | LedgerFeatures | D1-04 |
| URLSession client, status and error handling | LedgerData | D2-01 networking |
| Wire DTOs and the mapping border | LedgerData | D1-05 |
| Cache (memory + disk) | LedgerData | D2-03 caching |
| Outbox for offline writes | LedgerData | D2-04 offline sync |
| Token bytes, biometric gate | LedgerData → Keychain | D2-05 keychain |
| TokenVault single-flight | LedgerData (actor) | C2-01 actors |
| Background refresh | App target registers, LedgerData executes | P3-03 lifecycle |
| Tests at every layer | each package's test target | P1-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.
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:
- The screen appears and
.taskcallsload()..taskis 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. - The view model calls the repository. It calls
fetchFeed(), which is declared on a port — a protocol thatLedgerCoreowns andLedgerDataconforms to. The view model therefore has no idea whether rows come from memory, from disk or from the network. - 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.
- First paint. The view model sets its state to
.loaded(cached), one case of D1-04'sLoadStateenum. The user is reading yesterday's rows before the network has finished connecting. - Now the network request goes out. The user already has something to read, so nobody is waiting on it.
- 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.
- Second paint.
.loaded(fresh)swaps the new rows in.
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.
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.
Three lines in that code carry the pattern:
return await running.valueis the join. Any number of callers can await oneTask'svalue, and they all receive the same result.- The first caller stores
inFlight = workbefore it awaits anything. Creating aTaskis not a suspension point, so no second caller can slip in between those two lines. inFlight = nilruns once the refresh finishes. Without it, tomorrow morning's genuine expiry would await a task that completed yesterday and get yesterday's token back.
Read the diagram left to right:
- Two requests fail with a 401 at the same moment. Neither of them refreshes anything itself.
- Both call the same
TokenVaultactor. One refresh leaves the vault for the auth server — that is the arrow labelled "ONE refresh". - The new token bytes go into the Keychain, which is the only place they are ever stored.
- 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:
TokenVaultis an actor inLedgerData, behind a port the core owns — call itTokenProviding.LedgerCoreknows 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.AfterFirstUnlockmeans the item is readable at any point after the first unlock following a reboot. Background refresh needs exactly that. AWhenUnlockeditem cannot be read while the phone is locked in a pocket, which is the classic "sync works in testing, fails overnight" bug.ThisDeviceOnlymeans 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.
Read it left to right:
SendMoneyruns 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.- That row is on disk, so it survives the app being killed, the battery running out, and the tunnel.
- The sync engine drains the outbox when a network is available, sending each row to the API with its key attached.
- 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:
- The sync engine sends the €200 row.
- The server receives it and moves the money.
- The response is lost on the way back, because the train entered a tunnel.
- The app cannot know whether step 2 happened, so it retries. It has to: the alternative is losing payments.
- 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.
The diagram is one picture of two apps:
- 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.
- On the right, the legacy app. UIKit view controllers on top, and a
UIHostingControllerbox holding the features migrated so far. - Underneath both, one shared box:
LedgerCoreplusLedgerData, 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
AppDelegateadaptor, for callbacks SwiftUI does not surface. The main one in a bank app is receiving the push notification device token.@UIApplicationDelegateAdaptorconnects a UIKit app delegate to a SwiftUIApp. UIViewRepresentableislands, for capabilities SwiftUI does not have. AUIViewRepresentablewraps 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:
- Today, the "send money" route returns a storyboard view controller.
- Tomorrow, it returns
UIHostingController(rootView: SendMoneyFlow(deps: …)). AUIHostingControlleris 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. - 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.
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.
| Layer | What you assert | Harness | Share |
|---|---|---|---|
| LedgerCore | policies, invariants, use-case scenarios | plain structs + fakes, no simulator | most |
| LedgerData | DTO mapping, error translation, vault single-flight | D2-01's HealthyServer / BrokenServer / LyingServer | many |
| LedgerFeatures | LoadState transitions, route parsing | D1-04's ScriptedServer pattern | some |
| App shell | launch wiring, deep link → screen | a few UI tests | least |
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.
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.
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-
Tasksingle-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.