Architecture Patterns
Contents
MV Patterns
Default to Model-View (MV) in SwiftUI. Views are lightweight state expressions; models and services own business logic. Do not introduce view models unless the existing code already requires them.
Contents
- Core Principles
- Why Not MVVM
- MV Pattern in Practice
- When a ViewModel Already Exists
- When a New ViewModel Is Justified
- Environment vs. Initializer Injection
- Testing Strategy
- Source
Core Principles
- Views orchestrate UI flow using
@State,@Environment,@Query,.task, and.onChange - Services and shared models live in the environment, are testable in isolation, and encapsulate complexity
- Split large views into smaller subviews rather than introducing a view model
- Test models, services, and business logic; views should stay simple and declarative
Why Not MVVM
SwiftUI views are structs -- lightweight, disposable, and recreated frequently. Adding a ViewModel means fighting the framework's core design. Apple's own WWDC sessions (Data Flow Through SwiftUI, Data Essentials in SwiftUI, Discover Observation in SwiftUI) barely mention ViewModels.
Every ViewModel adds:
- More complexity and objects to synchronize
- More indirection and cognitive overhead
- Manual data fetching that duplicates SwiftUI/SwiftData mechanisms
MV Pattern in Practice
View with Environment-Injected Service
struct FeedView: View {
@Environment(FeedClient.self) private var client
@Environment(AppTheme.self) private var theme
enum ViewState {
case loading, error(String), loaded([Post])
}
@State private var viewState: ViewState = .loading
@State private var isRefreshing = false
var body: some View {
NavigationStack {
List {
switch viewState {
case .loading:
ProgressView("Loading feed...")
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
case .error(let message):
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(message))
.listRowSeparator(.hidden)
case .loaded(let posts):
ForEach(posts) { post in
PostRowView(post: post)
}
}
}
.listStyle(.plain)
.refreshable { await loadFeed() }
.task { await loadFeed() }
}
}
private func loadFeed() async {
do {
let posts = try await client.getFeed()
viewState = .loaded(posts)
} catch {
viewState = .error(error.localizedDescription)
}
}
}Using .task(id:) and .onChange
SwiftUI modifiers act as small state reducers:
.task(id: searchText) {
guard !searchText.isEmpty else { return }
await searchFeed(query: searchText)
}
.onChange(of: isInSearch, initial: false) {
guard !isInSearch else { return }
Task { await fetchSuggestedFeed() }
}App-Level Environment Setup
@main
struct MyApp: App {
@State var client = APIClient()
@State var auth = Auth()
@State var router = AppRouter(initialTab: .feed)
var body: some Scene {
WindowGroup {
ContentView()
.environment(client)
.environment(auth)
.environment(router)
}
}
}All dependencies are injected once and available everywhere.
SwiftData: The Perfect MV Example
SwiftData was built to work directly in views:
struct BookListView: View {
@Query private var books: [Book]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
ForEach(books) { book in
BookRowView(book: book)
.swipeActions {
Button("Delete", role: .destructive) {
modelContext.delete(book)
}
}
}
}
}
}Forcing a ViewModel here means manual fetching, manual refresh, and boilerplate everywhere.
When a ViewModel Already Exists
If a ViewModel exists in the codebase:
- Make it non-optional when possible
- Pass dependencies via
init, then forward them into the ViewModel in the view'sinit - Store as
@Statein the root view that owns it - Avoid
bootstrapIfNeededpatterns
@State private var viewModel: SomeViewModel
init(dependency: Dependency) {
_viewModel = State(initialValue: SomeViewModel(dependency: dependency))
}Modern @Observable ViewModel with child-view binding:
@MainActor @Observable final class ProfileViewModel {
var name: String = ""
var isSaving: Bool = false
private let client: ProfileClient
init(client: ProfileClient) {
self.client = client
}
func save() async throws {
isSaving = true
defer { isSaving = false }
try await client.update(name: name)
}
}
// Owner view creates via @State
struct ProfileScreen: View {
@State private var viewModel: ProfileViewModel
init(client: ProfileClient) {
_viewModel = State(initialValue: ProfileViewModel(client: client))
}
var body: some View {
ProfileForm(viewModel: viewModel)
}
}
// Child view receives and binds
struct ProfileForm: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Name", text: $viewModel.name)
Button("Save") { Task { try? await viewModel.save() } }
.disabled(viewModel.isSaving)
}
}Use @MainActor for observable types that are owned by SwiftUI views and mutate view-facing state. Keep non-UI domain models isolated according to their concurrency boundary instead of applying @MainActor by default.
When a New ViewModel Is Justified
The MV pattern is the default. Introduce a ViewModel only when the view would be hard to read or test without one:
- Multi-step workflows — onboarding, checkout, or wizard flows where each step mutates shared draft state
- Non-trivial business logic — validation chains, derived state from multiple sources, or transformation pipelines that don't belong in a lightweight client
- Coordinated async streams — the view orchestrates multiple publishers or
AsyncSequencevalues with interdependent state transitions - Existing test surface — the codebase already tests against a ViewModel interface and rewriting to MV would be high cost, low reward
The bar is "this view would be hard to read and test without a ViewModel," not "I'm used to MVVM."
Environment vs. Initializer Injection
Use @Environment when the dependency is shared across many views at different depths. Threading it through every intermediate initializer adds noise:
- App-wide services: auth, network client, theme, router
- SwiftData
ModelContext - Feature-scoped stores injected at a navigation root
Use initializer parameters when the data is specific to this view instance. Makes the view's requirements explicit and keeps previews simple:
- The selected item, filter mode, or configuration
- Parent-to-child data that only one view needs
- Values known at call site that don't change
Rule of thumb: if three or more intermediate views would need to accept and forward a parameter just to reach a deeply nested consumer, move it to the environment.
Testing Strategy
- Unit test services and business logic
- Test models and transformations
- Use SwiftUI previews for visual regression
- Use UI automation for end-to-end tests
- Views should be simple enough that they do not need dedicated unit tests
Source
Based on guidance from "SwiftUI in 2025: Forget MVVM" (Thomas Ricouard) and Apple WWDC sessions on SwiftUI data flow.
App Wiring and Dependency Graph
Contents
- Intent
- Recommended Structure
- Root Shell Example
- Dependency Graph Modifier
- SwiftData / ModelContainer
- Sheet Routing (Enum-Driven)
- App Entry Point
- Deep Linking
- When to Use
- Caveats
Intent
Wire the app shell (TabView + NavigationStack + sheets) and install a global dependency graph (environment objects, services, streaming clients, SwiftData ModelContainer) in one place.
Recommended Structure
- Root view sets up tabs, per-tab routers, and sheets.
- A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers).
- Feature views pull only what they need from the environment; feature-specific state stays local.
Root Shell Example
@MainActor
struct AppView: View {
@State private var selectedTab: AppTab = .home
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
let router = tabRouter.router(for: tab)
Tab(value: tab) {
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.withSheetDestinations(sheet: Binding(
get: { router.presentedSheet },
set: { router.presentedSheet = $0 }
))
.environment(router)
} label: {
tab.label
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.withAppDependencyGraph()
}
}AppTab Enum
@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
case home, notifications, settings
var id: String { String(describing: self) }
@ViewBuilder
func makeContentView() -> some View {
switch self {
case .home: HomeView()
case .notifications: NotificationsView()
case .settings: SettingsView()
}
}
@ViewBuilder
var label: some View {
switch self {
case .home: Label("Home", systemImage: "house")
case .notifications: Label("Notifications", systemImage: "bell")
case .settings: Label("Settings", systemImage: "gear")
}
}
}Router Skeleton
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
enum Route: Hashable {
case detail(id: String)
}Dependency Graph Modifier
Use a single modifier to install environment objects and handle lifecycle hooks. This keeps wiring consistent and avoids forgetting a dependency at call sites.
extension View {
func withAppDependencyGraph(
client: APIClient = .shared,
auth: Auth = .shared,
theme: Theme = .shared,
toastCenter: ToastCenter = .shared
) -> some View {
environment(client)
.environment(auth)
.environment(theme)
.environment(toastCenter)
.task(id: auth.currentAccount?.id) {
// Re-seed services when account changes
await client.configure(for: auth.currentAccount)
}
}
}Notes:
- The
.task(id:)hooks respond to account/client changes, re-seeding services and watcher state. - Keep the modifier focused on global wiring; feature-specific state stays within features.
- Adjust types to match your project.
SwiftData / ModelContainer
Install ModelContainer at the root so all feature views share the same store:
extension View {
func withModelContainer() -> some View {
modelContainer(for: [Draft.self, LocalTimeline.self, TagGroup.self])
}
}A single container avoids duplicated stores per sheet or tab and keeps data consistent.
Sheet Routing (Enum-Driven)
Centralize sheets with a small enum and a helper modifier:
enum SheetDestination: Identifiable {
case composer
case settings
var id: String { String(describing: self) }
}
extension View {
func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
sheet(item: sheet) { destination in
switch destination {
case .composer:
ComposerView().withEnvironments()
case .settings:
SettingsView().withEnvironments()
}
}
}
}Enum-driven sheets keep presentation centralized and testable; adding a new sheet means one enum case and one switch branch.
App Entry Point
@main
struct MyApp: App {
@State var client = APIClient()
@State var auth = Auth()
@State var router = AppRouter(initialTab: .home)
var body: some Scene {
WindowGroup {
AppView()
.environment(client)
.environment(auth)
.environment(router)
}
}
}Deep Linking
Store NavigationPath as Codable for state restoration. Handle incoming URLs with .onOpenURL:
.onOpenURL { url in
guard let route = Route(from: url) else { return }
router.navigate(to: route)
}See the swiftui-navigation skill for full URL routing patterns.
When to Use
- Apps with multiple packages/modules that share environment objects and services
- Apps that need to react to account/client changes and rewire streaming/push safely
- Any app that wants consistent TabView + NavigationStack + sheet wiring without repeating environment setup
Caveats
- Keep the dependency modifier slim; do not put feature state or heavy logic there
- Ensure
.task(id:)work is lightweight or cancelled appropriately; long-running work belongs in services - If unauthenticated clients exist, gate streaming/watch calls to avoid reconnect spam
Lightweight Clients
Use this pattern to keep networking or service dependencies simple and testable without introducing a full view model or heavy DI framework. It works well for SwiftUI apps where you want a small, composable API surface that can be swapped in previews/tests.
Intent
- Provide a tiny "client" type made of async closures.
- Keep business logic in a store or feature layer, not the view.
- Enable easy stubbing in previews/tests.
Minimal shape
struct SomeClient {
var fetchItems: (_ limit: Int) async throws -> [Item]
var search: (_ query: String, _ limit: Int) async throws -> [Item]
}
extension SomeClient {
static func live(baseURL: URL = URL(string: "https://example.com")!) -> SomeClient {
let session = URLSession.shared // Prototyping only. For production, create a URLSession with timeoutIntervalForRequest: 30, timeoutIntervalForResource: 300, waitsForConnectivity: true, and a URLCache.
return SomeClient(
fetchItems: { limit in
// build URL, call session, decode
},
search: { query, limit in
// build URL, call session, decode
}
)
}
}Usage pattern
@MainActor
@Observable final class ItemsStore {
enum LoadState { case idle, loading, loaded, failed(String) }
var items: [Item] = []
var state: LoadState = .idle
private let client: SomeClient
init(client: SomeClient) {
self.client = client
}
func load(limit: Int = 20) async {
state = .loading
do {
items = try await client.fetchItems(limit)
state = .loaded
} catch {
state = .failed(error.localizedDescription)
}
}
}struct ContentView: View {
@Environment(ItemsStore.self) private var store
var body: some View {
List(store.items) { item in
Text(item.title)
}
.task { await store.load() }
}
}@main
struct MyApp: App {
@State private var store = ItemsStore(client: .live())
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}Guidance
- Keep decoding and URL-building in the client; keep state changes in the store.
- Make the store accept the client in
initand keep it private. - Avoid global singletons; use
.environmentfor store injection. - If you need multiple variants (mock/stub), add
static func mock(...).
Pitfalls
- Don’t put UI state in the client; keep state in the store.
- Don’t capture
selfor view state in the client closures.