Skip to content
AgileLogics
6 min read

App Architecture

Prerequisites: SwiftUI state and data flow, Protocol-oriented programming

Architecture is how an app's code is divided into parts, and how those parts are allowed to depend on each other. Good architecture keeps each part focused, testable, and replaceable.

A practical layered structure

Most SwiftUI apps settle on something like this:

  • Views — layout and user interaction only. They display state and forward user actions.
  • View models / feature models — @Observable classes holding a screen's state and logic: loading, validation, formatting.
  • Services — networking, persistence, authentication. Defined by protocols, so they can be swapped for fakes.
  • Models — plain structs such as User and Order, usually Codable.

This is often called MVVM (Model–View–ViewModel).

A feature, end to end

import SwiftUI

struct Article: Identifiable, Decodable {
    let id: Int
    let title: String
}

protocol ArticleService: Sendable {
    func latest() async throws -> [Article]
}

@MainActor
@Observable
final class ArticlesModel {
    enum State { case loading, loaded([Article]), failed(String) }

    private(set) var state: State = .loading
    private let service: any ArticleService

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

    func load() async {
        state = .loading
        do {
            state = .loaded(try await service.latest())
        } catch {
            state = .failed("Couldn't load articles.")
        }
    }
}

struct ArticlesView: View {
    @State var model: ArticlesModel

    var body: some View {
        Group {
            switch model.state {
            case .loading:
                ProgressView()
            case .loaded(let articles):
                List(articles) { Text($0.title) }
            case .failed(let message):
                ContentUnavailableView(message, systemImage: "wifi.slash")
            }
        }
        .task { await model.load() }
    }
}
  • The view contains no networking and no decisions beyond displaying the current state.
  • The model depends on ArticleService, a protocol — the real implementation uses URLSession; tests pass a fake. The protocol inherits Sendable because the main-actor model calls it from async code, and Swift 6 needs to know that's safe.
  • The State enum makes it impossible to be "loading" and "failed" at the same time.

Dependency injection

Passing dependencies in through initializers (as service is above) is called dependency injection. Create the real services once, near the app's entry point, and hand them down. It makes every piece testable in isolation and dependencies visible at a glance.

Testing the model

import Testing

struct StubService: ArticleService {
    func latest() async throws -> [Article] { [Article(id: 1, title: "Hello")] }
}

@MainActor
@Test func loadsArticles() async {
    let model = ArticlesModel(service: StubService())
    await model.load()
    guard case .loaded(let articles) = model.state else {
        Issue.record("Expected loaded state")
        return
    }
    #expect(articles.count == 1)
}

Keep it proportional

A small app doesn't need every layer. Start simple, and introduce separation when a view grows too large to follow or you need to test its logic. Other architectures you'll hear about — such as The Composable Architecture (TCA) — trade more structure for stronger guarantees; they're worth learning once the basics feel natural.