Skip to main content
3Nsofts logo3Nsofts

Insights / iOS Architecture

SwiftUI Architecture Patterns for Production Apps: What Scales and What Doesn't

SwiftUI makes it fast to build something that looks like an app. That same speed makes it easy to accumulate structural debt that compounds as screens multiply. This guide covers the patterns that hold under production conditions — and the ones that don't.

By Ehsan Azish · 3NSOFTS · May 2026

The prototype-to-production gap

A prototype with five views and one @StateObject is fine. An app with forty screens, background sync, and on-device AI inference built on the same pattern is not.

The architecture that felt adequate at week two becomes the thing you're rewriting at week twelve. The patterns described here are not theoretical — they reflect the constraints that appear in real production apps with background sync, on-device AI inference, multi-screen navigation, and codebases that need to absorb requirements that weren't known at the start.

MVVM in SwiftUI: where it works and where it breaks

MVVM is the default recommendation for SwiftUI architecture. Correct in principle, frequently misapplied in practice.

The pattern works when view models are scoped correctly — one per screen, with a clear boundary between UI state and domain logic. It breaks when view models become catch-all objects: network calls, Core Data fetch logic, navigation state, and formatting code all living in the same class.

The view model debt problem

The failure mode is predictable. A view model starts with a few @Published properties. A new requirement arrives, and the path of least resistance is adding it to the existing class. Six weeks later, that class is 600 lines covering concerns that have nothing to do with each other.

The fix is not a different pattern — it is stricter scoping. A view model holds UI state and coordinates between services. It does not own the services. Fetch logic lives in a repository. Business rules live in a domain layer. The view model calls both.

Actor-isolated view models

Swift 6 makes the isolation requirement explicit. View models that update @Published properties from background threads produce data races — and Swift 6 surfaces those as compile errors rather than runtime crashes.

The correct architecture uses @MainActor isolation on the view model class:

@MainActor
final class InventoryViewModel: ObservableObject {
    @Published private(set) var items: [InventoryItem] = []
    private let repository: InventoryRepository

    init(repository: InventoryRepository) {
        self.repository = repository
    }

    func loadItems() async {
        items = await repository.fetchAll()
    }
}

The view model is actor-isolated to the main actor. The repository does its work off-actor. The boundary is explicit and enforced by the compiler. Swift concurrency patterns for AI workloads →

The Observation framework vs ObservableObject

For new production apps targeting iOS 17+, the choice is clear: @Observable from the Observation framework, or the older ObservableObject / @Published pattern.

ObservableObject invalidates the entire view when any @Published property changes. In a view with ten properties, a change to one redraws everything. At scale, this produces unnecessary render cycles that are difficult to trace.

@Observable tracks property access at the point of use. Only the views that actually read a changed property re-render. The performance difference is measurable in complex screens.

@Observable
final class OrderViewModel {
    var pendingOrders: [Order] = []
    var selectedOrder: Order?
    var isLoading = false
}

No @Published annotations. No ObservableObject conformance. SwiftUI tracks which properties each view body reads and invalidates only those views. For apps targeting iOS 16 and below, ObservableObject remains the correct choice.

Data flow architecture

Single source of truth

The most common structural error in SwiftUI apps is duplicated state. A view holds a local @State copy of data that also lives in a view model, which also mirrors data from a Core Data store. Three representations of the same fact, with no guaranteed consistency between them.

The architecture that avoids this: one authoritative source per piece of data, with views reading from that source rather than copying from it. For persistent data, NSPersistentCloudKitContainer or SwiftData's ModelContainer is the source. Views observe the store directly using @FetchRequest or @Query — no intermediate copy in a view model.

For transient UI state — loading indicators, selection state, form input — @State in the view is correct. That state belongs to the view and nowhere else.

Environment vs dependency injection

@EnvironmentObject is convenient and creates implicit coupling. Any view in the hierarchy can read any environment object, which means any view can depend on any service without that dependency being visible at the call site.

Explicit dependency injection — passing services through init — keeps dependencies visible and testable. The view model receives exactly what it needs. Nothing more.

For app-wide singletons that genuinely belong in the environment (the Core Data context, a router), @Environment with a custom key is appropriate. For service objects scoped to a specific screen, pass them explicitly.

Navigation architecture

NavigationLink with destination closures does not scale past a handful of screens. Navigation logic scatters across the view hierarchy, deep linking becomes difficult to implement, and testing navigation state requires constructing the full view tree.

NavigationStack with a path binding centralises navigation state. Combined with a typed route enum, the architecture becomes deterministic — any navigation state can be constructed programmatically, tested without UI, and restored from a serialized path.

enum AppRoute: Hashable {
    case orderDetail(Order.ID)
    case inventoryItem(InventoryItem.ID)
    case settings
}

struct AppNavigationView: View {
    @State private var path: [AppRoute] = []

    var body: some View {
        NavigationStack(path: $path) {
            RootView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .orderDetail(let id): OrderDetailView(id: id)
                    case .inventoryItem(let id): InventoryItemView(id: id)
                    case .settings: SettingsView()
                    }
                }
        }
    }
}

Deep links map to AppRoute values. Programmatic navigation appends to path. The entire navigation state is a plain array — serializable, testable, and inspectable.

Modular view hierarchies

SwiftUI views are cheap to create. The temptation is to build large, monolithic view bodies that handle every layout case inline. A view body with 200 lines of conditional logic is not a view — it is a template engine.

The rule that prevents this: if a section of a view body has a distinct purpose, it becomes a separate view. Not a function returning some View — a named struct with its own file.

This is not about performance. It is about the ability to understand, modify, and test each component in isolation. An OrderSummaryCard view can be previewed with mock data, tested for layout correctness, and reused across screens. An inline block of conditional VStack code cannot.

The boundary is drawn at purpose, not at size. A three-line view that represents a distinct domain concept belongs in its own struct.

Patterns that don't scale

Some patterns appear in tutorials and early-stage apps that create structural problems at production scale. Worth naming directly.

Singleton view models

A shared @StateObject passed through the environment to every screen. Any screen can mutate shared state. Debugging a state corruption issue requires tracing every write across the entire app.

View-layer business logic

Validation, formatting, and domain rules written directly in body or in @State computed properties. This logic is untestable and gets duplicated whenever the same rule applies to a second screen.

Untyped navigation

String-based deep link parsing or NavigationLink destination closures that construct views with inline data. Navigation state is not inspectable, and adding a new route requires modifying multiple files.

Unscoped @EnvironmentObject

Injecting a large app-state object into the environment and reading from it across unrelated screens. The dependency graph becomes invisible, and any change to the shared object risks breaking screens with no obvious relationship to the changed data.

Each of these patterns feels neutral early and becomes expensive later. The cost shows up in debugging time, rewrite scope, and the difficulty of bringing new engineers into the codebase.

What production-grade SwiftUI looks like in practice

The patterns above converge on a structure that is predictable and maintainable at scale:

  • @MainActor-isolated view models that hold only UI state and call into repositories or services for data access and business logic.
  • @Observable for all new iOS 17+ development. ObservableObject only where iOS 16 support is required.
  • NavigationStack with typed routes from the first screen. Retrofitting type-safe navigation into an existing app that uses inline destination closures is a significant refactor.
  • Explicit dependency injection for all services scoped to a screen. @Environment only for app-wide singletons that genuinely belong globally.
  • Component boundaries drawn at purpose, not line count. Every distinct UI concept is a named struct that can be previewed and tested in isolation.

For teams integrating on-device AI features, the actor isolation requirement intersects directly with Core ML service design — the inference actor sits below the view model layer and surfaces results through async functions. SwiftUI + Core ML architecture patterns →

SwiftData vs Core Data in 2026: the data layer decision →

FAQs

What is the most common SwiftUI architecture pattern for production iOS apps in 2026?

MVVM remains the most widely used pattern, typically combined with the @Observable macro (iOS 17+), explicit dependency injection, and NavigationStack with typed routes. The pattern itself matters less than the discipline of scoping — keeping view models focused on UI state and separating domain logic into distinct layers.

Should I use @Observable or ObservableObject for new SwiftUI projects?

For apps targeting iOS 17 and above, @Observable is the correct choice. It tracks property access at the point of use, so only the views that read a changed property re-render. ObservableObject invalidates the entire view on any @Published change. For iOS 16 support, ObservableObject is still required.

How do you handle navigation in a large SwiftUI app?

NavigationStack with a typed route enum and a path binding. Navigation state becomes a plain array of route values — programmatically settable, serializable for deep links, and testable without constructing the view hierarchy. Scattered NavigationLink destination closures do not scale past a handful of screens.

What is view model debt in SwiftUI?

View model debt accumulates when a single ObservableObject or @Observable class absorbs concerns beyond UI state — network calls, persistence logic, business rules, navigation state. The class grows until testing is expensive and any change risks unintended side effects. The fix is stricter scoping from the start, not a different architecture pattern.

How does Swift 6 affect SwiftUI architecture?

Swift 6 enforces strict concurrency checking at compile time. View models that update published properties from background threads produce data race errors rather than runtime crashes. The architectural response is @MainActor isolation on view model classes, with async work delegated to repositories or services that run off the main actor.

When should a SwiftUI view be extracted into its own struct?

When a section of a view body has a distinct purpose — not when it reaches a specific line count. A named struct can be previewed in isolation, tested with mock data, and reused across screens. An inline block of conditional layout code cannot. The boundary is drawn at purpose.

How do you structure dependency injection in a SwiftUI app without @EnvironmentObject?

Pass services through init on view models. The view model receives exactly the dependencies it needs, those dependencies are visible at the call site, and the view model can be tested by substituting mock implementations. Reserve @Environment with custom keys for genuinely app-wide singletons — the managed object context, a router — where threading dependencies through init across many layers is impractical.

Related articles

Inheriting a SwiftUI codebase with architecture problems?

We've audited and restructured iOS apps where view model debt and untyped navigation were blocking further development. An architecture audit delivers written recommendations in 5 business days.