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 —
@Observableclasses 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
UserandOrder, usuallyCodable.
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 usesURLSession; tests pass a fake. The protocol inheritsSendablebecause the main-actor model calls it from async code, and Swift 6 needs to know that's safe. - The
Stateenum 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.