iOS with SwiftUI · Production: Architecture, Testing, Accessibility and the App Store

App Architecture and Dependency Injection

Organise a SwiftUI app into views, observable view models and services behind protocols, inject dependencies through initializers and the environment, and swap in fakes for previews and tests.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

The structure for the shop app: each feature has a view, an @Observable view model and services behind protocols; the app wires real services, while previews and tests get fakes.

The idea

SwiftUI doesn't force an architecture, but most production apps converge on:

  • Views: layout and user input only.
  • View models (@Observable, @MainActor): screen state and logic; they call services.
  • Services / repositories: networking, persistence, analytics, behind protocols.
  • Dependency injection: view models receive services through their initializer; app-wide objects travel through the environment.

This keeps logic testable without UI and lets previews show any state without a network. Keep it proportional: a small screen with no logic doesn't need a view model.

The code

protocol OrderService: Sendable {
    func orders() async throws -> [Order]
    func cancel(orderID: Int) async throws
}

@MainActor @Observable
final class OrdersModel {
    private(set) var orders: [Order] = []
    private(set) var error: String?
    private let service: any OrderService

    init(service: any OrderService) { self.service = service }

    func load() async {
        do { orders = try await service.orders(); error = nil }
        catch { self.error = "Couldn't load orders." }
    }

    func cancel(_ order: Order) async {
        let previous = orders
        orders.removeAll { $0.id == order.id }                  // optimistic update
        do { try await service.cancel(orderID: order.id) }
        catch { orders = previous; self.error = "Couldn't cancel order #\(order.id)." }
    }
}

struct AppDependencies {
    let orders: any OrderService
    static let live = AppDependencies(orders: LiveOrderService(baseURL: URL(string: "https://api.example.com")!))
    static let preview = AppDependencies(orders: FakeOrderService(orders: Order.samples))
}

struct OrdersScreen: View {
    @State private var model: OrdersModel
    init(dependencies: AppDependencies) { _model = State(initialValue: OrdersModel(service: dependencies.orders)) }
    var body: some View { OrdersList(model: model).task { await model.load() } }
}

#Preview { OrdersScreen(dependencies: .preview) }

Try the logic

The view model running against a fake service, including the rollback when an optimistic cancel fails:

A view model with an injected fake service
struct Order: Equatable, Sendable { let id: Int; let total: Double }

protocol OrderService: Sendable {
    func orders() async throws -> [Order]
    func cancel(orderID: Int) async throws
}

struct ServerError: Error {}

struct FakeOrderService: OrderService {
    let stored: [Order]
    let failingCancels: Set<Int>
    func orders() async throws -> [Order] { stored }
    func cancel(orderID: Int) async throws { if failingCancels.contains(orderID) { throw ServerError() } }
}

@MainActor
final class OrdersModel {
    private(set) var orders: [Order] = []
    private(set) var error: String?
    private let service: any OrderService
    init(service: any OrderService) { self.service = service }

    func load() async {
        do { orders = try await service.orders(); error = nil } catch { self.error = "Couldn't load orders." }
    }

    func cancel(_ order: Order) async {
        let previous = orders
        orders.removeAll { $0.id == order.id }
        do { try await service.cancel(orderID: order.id) }
        catch { orders = previous; self.error = "Couldn't cancel order #\(order.id)." }
    }
}

let service = FakeOrderService(stored: [Order(id: 1, total: 40), Order(id: 2, total: 15.5)], failingCancels: [2])
let model = OrdersModel(service: service)
await model.load()
print("loaded:", model.orders.map(\.id))
await model.cancel(model.orders[0])
print("after cancelling #1:", model.orders.map(\.id), model.error ?? "no error")
await model.cancel(model.orders[0])
print("after cancelling #2:", model.orders.map(\.id), model.error ?? "no error")

Output

loaded: [1, 2]
after cancelling #1: [2] no error
after cancelling #2: [2] Couldn't cancel order #2.

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Views that call URLSession directly. Logic becomes untestable and previews need a network.
  • Global singletons for services. Hidden dependencies make tests interfere with each other; inject instead.
  • One giant app-wide view model. Every screen re-renders and changes collide; keep a model per feature.
  • Over-architecting. Protocols and layers for a two-screen app add cost without benefit; grow the structure with the app.

Key points

  • Views render; @Observable @MainActor view models hold state and logic; services sit behind protocols.
  • Inject services through initializers; use the environment for app-wide objects.
  • Fakes make previews and tests fast and deterministic.
  • Optimistic updates need a rollback path.

Further reading: Model data, Environment values.

Practice questions

Why put services behind protocols in a SwiftUI app?

Answer

View models depend on the protocol, not the concrete networking or database code, so tests and previews can inject fakes that return fixed data or errors instantly. It also isolates the rest of the app from changes in how data is fetched.

What is an optimistic update and what must it handle?

Answer

Updating the UI immediately as if a server request succeeded (removing a cancelled order) to feel instant, then sending the request. It must keep the previous state and roll back, with a message, if the request fails.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?