Ask what architecture an iOS app should use and you get acronyms: MVC, MVVM, VIPER, TCA, Clean. Most of that conversation is about naming, and it skips the decision that actually determines whether a codebase stays readable — which is which direction the dependencies point.
This lesson is the layering the demo app uses, why MVVM is not an architecture, and the one piece of state worth being strict about.
MVVM is a third of the answer
MVVM says: a view renders, a view model holds its state and logic, a model is the data. That is useful and it is not an architecture. It says nothing about where the networking lives, what a view model is allowed to depend on, or how you would swap a backend for a fake.
An app can be perfectly MVVM and still have a view model that constructs a
URLSession, parses JSON, caches to disk and knows the shape of the API. All the logic
is out of the view, and the codebase is still one tangled layer.
Four layers, dependencies pointing inwards
Pizza/
App/ composition root, routing, app lifecycle
Core/ networking, persistence, design system, utilities — knows nothing about pizza
Domain/ models, pure rules, repository protocols
Data/ endpoints and the HTTP implementations of those protocols
Features/ Home · Menu · Cart · Checkout · Orders · Auth · Profile
Features depends on Domain. Data depends on
Domain. Domain depends on nothing. That inversion is the whole design, and
it is worth being concrete about what it buys before looking at how it is done.
The boundary: protocols in Domain, implementations in Data
protocol OrderRepository: Sendable {
/// `authenticated` is a parameter rather than a constant because this same call serves guests.
/// With a token the order is attached to the account; without one, `guestEmail` is how the
/// customer gets their receipt.
func createOrder(_ request: OrderCreateRequest, authenticated: Bool) async throws -> OrderCreateResponse
/// Asks the SERVER whether the payment settled. Deliberately not "mark this order paid" — the
/// device is never the authority on money, and anyone can call our API.
func paymentStatus(orderID: UUIDString) async throws -> Order
func myOrders(page: Int, size: Int) async throws -> Page<Order>
}
The protocol is declared in the domain layer and implemented in the data layer. So the feature code depends on an abstraction it owns, and the networking code depends on it too — both point inward at the same thing.
Three consequences, in increasing order of how much they matter:
- Nothing under
Features/importsURLSession,Endpointor evenAPIErrorby way of a repository. - Swapping the transport — GraphQL, a local database, an offline cache — touches
Data/and nothing else. - Testing needs no server. A test injects a repository that returns three pizzas from an array, and the view model under test cannot tell.
That last one is the practical payoff, and it is why this is worth doing in an app of any size rather than only in a large one.
Every method is async throws
No Result, no completion handler. Swift concurrency makes the error path the
language's own, so a caller that forgets to handle a failure does not compile. And a protocol full
of async throws methods is trivially implementable by a test double, which the
completion-handler shape is not.
Keep the implementations thin
struct RemoteOrderRepository: OrderRepository {
private let client: HTTPClient
init(client: HTTPClient) { self.client = client }
func createOrder(
_ request: OrderCreateRequest,
authenticated: Bool
) async throws -> OrderCreateResponse {
try await client.send(OrderEndpoints.create(request, authenticated: authenticated))
}
An endpoint in, a decoded model out. The thinness is deliberate: a repository that also cached, retried or mapped errors would be a place for logic to accumulate where no test would look for it. Caching belongs in a store, retrying belongs to the caller that knows whether it is safe, and error mapping already happened in the client.
They are structs holding one let — values, cheap to make, impossible to
mutate into an inconsistent state, and Sendable without further thought.
Where the rules live
The domain layer is not just models. It is also the rules that are pure functions of them, and pulling those out is where most of the testing value comes from.
enum CartPricing {
static let taxRate = 0.085
static let deliveryFee = 3.99
/// Price of ONE unit of a cart line: base size price + crust surcharge + toppings.
static func unitPrice(of item: CartItem) -> Double {
let toppingsTotal = item.toppings.reduce(0) { $0 + $1.price }
return Money.rounded(item.basePrice + item.crustPriceDelta + toppingsTotal)
}
No SwiftUI, no networking, no storage. Nothing here performs I/O, which is what makes the whole file unit-testable in milliseconds — and it is the part of the app most worth testing, because it is the part a customer checks.
The state worth being strict about
Most screen state is fine as properties on an observable object. The cart is not most state: four screens read it, three can change it, and getting it wrong means charging somebody for the wrong pizza.
enum CartAction: Equatable {
case add(CartItem)
case remove(lineID: UUID)
case setQuantity(lineID: UUID, quantity: Int)
case setOrderType(OrderType)
/// Replace everything with what the server had saved.
case hydrate(CartState)
case clear
}
Every way the cart can change, as a closed set. Then one pure function applies them:
static func reduce(_ state: CartState, _ action: CartAction) -> CartState {
var state = state
switch action {
case let .add(item):
if let index = state.items.firstIndex(where: { $0.hasSameConfiguration(as: item) }) {
state.items[index].quantity += item.quantity
} else {
state.items.append(item)
}
var state = state copies, because CartState is a value — so the input
is never mutated and the same state plus the same action always produces the same result.
Why bother, when @Observable already works
The store could mutate items directly and SwiftUI would redraw. Three reasons it does
not, and they matter more as an app grows.
The rules are testable without the app. reduce is a function from a
value to a value. Its tests construct a state, apply an action and assert — no view, no network, no
async, no main actor. Fifteen of them run in microseconds.
Every mutation has a name. "The quantity changed" is an event with a spelling, not an assignment somewhere in a view's closure. When a cart ends up in a state nobody expected, there is a finite list of things that could have caused it.
The store is left with one job. Everything in CartStore is now
effects — persistence, hydration, the background flush — and none of it is tangled with the rules.
The cost is indirection, and it is a real cost. For four fields of screen-local form state a reducer would be ceremony. For the cart it pays for itself, and knowing which is which is the actual skill.
One funnel
private func dispatch(_ action: CartAction) {
state = CartReducer.reduce(state, action)
schedulePersist()
}
One place applies the reducer and one place schedules the write, so a new command cannot forget to persist — which is the mistake that leaves a cart looking right until the app restarts.
What the tests look like
func testAddingTheSameConfigurationBumpsTheQuantity() {
var state = CartReducer.reduce(.initial, .add(item(quantity: 2)))
state = CartReducer.reduce(state, .add(item(quantity: 3)))
XCTAssertEqual(state.items.count, 1, "The same pizza twice is one line, not two.")
XCTAssertEqual(state.items[0].quantity, 5)
}
Three lines, no setup. The interesting ones are the negative cases — that a different topping set is a different line, and that topping order is not part of a pizza's identity, because {pepperoni, mushroom} and {mushroom, pepperoni} are one configuration. Those are exactly the rules that are hard to hold in your head and easy to break in a refactor.
Feature folders
Inside Features/, group by what a file is for, not by what it is. All the
view models together reads well in a tutorial and badly at scale, because one change then touches
four folders and no folder tells you what the app does.
A feature owns its state, its screens and its components, and opening
Features/Checkout/ shows you everything checkout is. The rule that keeps it honest:
shared things move down, never sideways. When two features need the same component
it moves into the design system, not into whichever feature got there first. A feature importing
from a sibling feature is the smell that says something belongs a level lower.
One deliberate exception in this app: checkout reads the cart's store, because a checkout without a cart is meaningless. That is a real dependency rather than a shortcut, and it points in one direction only.
Where a view model fits
With the layers in place, a view model becomes small: it holds screen state, calls repositories,
and exposes something a view can render. CheckoutViewModel is the largest in the app
and it is still only form state, a two-step flow and the payment outcomes.
And several screens have none. A view model earns its place where there is state with rules or derived values worth testing — not by decree. A codebase where every screen has one has stopped making the decision.
The models are the contract
One more thing lives in Domain: the types the API speaks in. They are plain
Codable values with no behaviour beyond what belongs to them — an Order
knows how to format its own address, a SizeName knows it displays as "Medium".
What they deliberately do not know is how they are fetched. That is the line that keeps the domain
independent, and it is easy to cross without noticing: the moment a model has a
static func fetch() on it, the layering is gone and every test of that model needs a
network.
What this is not
This is not Clean Architecture, and there are no use-case objects. A LoadMenuUseCase
wrapping a single repository call is a file that exists to satisfy a diagram. When a piece of logic
genuinely spans repositories, it becomes a type in Domain/Services — which is where
CartPricing and CartReducer already live.
The test for adding a layer is whether it removes a dependency or only renames one. By that measure the repository protocols earn their place — they genuinely stop features knowing about HTTP — and a use-case wrapper would not, because the feature would depend on exactly the same things through one more file.
Next
Repositories are protocols, so something has to decide which implementations the app runs with.
The next lesson is that decision: a composition root instead of singletons, and why a
static shared is a testing problem before it is a design one.