iOS – Architecture: Layers That Hold

August 22, 20268 min readUpdated 9/18/2026

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/ imports URLSession, Endpoint or even APIError by 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.