Skip to main content
3Nsofts logo3Nsofts
Production Guide · March 2026

SwiftUI Architecture Patterns for Production Apps

Practical architecture decisions for SwiftUI apps that need to scale: team size, feature count, and codebase longevity. MVVM patterns, state management, navigation, modularization, performance, and testing — grounded in Apple's documented stack.

Audience

Intermediate → Senior iOS

Read time

~30 minutes

Code patterns

10 production patterns

Architecture choice

Most SwiftUI architecture debates in developer communities conflate two separate questions: how to structure data flow within a feature, and how to structure the app module boundary. These have different answers.

For data flow within a feature: MVVM using @Observable (iOS 17+) or ObservableObject for older targets is the production default. It is idiomatic, documented by Apple, and requires zero dependencies. TCA (The Composable Architecture) provides strict unidirectional flow and more testable side effects but costs significantly more code weight — appropriate for teams that need formal state machines.

For app module boundaries: Feature packages (see the Modularization section below) are the correct tool. Architecture pattern choice and module structure are independent decisions. A monolithic MVVM app can be poorly structured; a modular TCA app can still have tangled dependencies.

// iOS 17+: prefer @Observable — lower overhead than ObservableObject
@Observable
final class FeedViewModel {
    var items: [FeedItem] = []
    var isLoading = false
    private let repository: FeedRepository

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

    func load() async {
        isLoading = true
        defer { isLoading = false }
        items = await repository.fetchFeed()
    }
}

// iOS 16 and earlier: ObservableObject + @Published
final class FeedViewModelLegacy: ObservableObject {
    @Published var items: [FeedItem] = []
    @Published var isLoading = false
    // ...
}

State management patterns

SwiftUI has multiple state tools and choosing the wrong one causes either over-rendering or prop-drilling. Use this decision map:

ScopeToolWhen to use
View-local, simple@StateToggle state, text field value, scroll position
View-local, complex@State + @Observable modelMulti-property view state, form editing
Parent → childBinding / direct propertyComponent input, controlled input fields
Feature-wide@Observable + environmentObjectFeature ViewModel accessed across a screen stack
App-wide@Observable + .environmentAuth state, theme, routing coordinator
PersistedSwiftData @ModelAny data that outlives an app session

A common mistake: using @EnvironmentObject for entire app state, which causes all views in the tree to re-evaluate on any change. Scope observability to the smallest relevant subtree.

// Scope environment narrowly — only views that need auth state subscribe
struct AuthenticatedRootView: View {
    @State private var authState = AuthViewModel()

    var body: some View {
        FeedView()
            .environment(authState)   // @Observable, only propagates to consumers
    }
}

// View only re-evaluates when properties it actually reads change
struct ProfileBadgeView: View {
    @Environment(AuthViewModel.self) var auth
    var body: some View {
        Text(auth.displayName)   // Only subscribes to displayName
    }
}

Modularization

Modularization using Swift Package Manager targets reduces compile times, enforces dependency direction, and enables isolated testing. The recommended structure for a medium-complexity production app:

// Package.swift structure
// MyApp (Xcode target) → depends on features
// Features depend on shared packages only, never on each other

Packages/
  FeatureFeed/          // Feed list, detail, filter logic
  FeatureProfile/       // Profile view, edit, settings
  FeatureOnboarding/    // Onboarding screens and state
  SharedUI/             // Design system components, tokens
  SharedData/           // Models, repositories, SwiftData schemas
  SharedNetworking/     // API clients, auth adapters
  SharedTesting/        // Factories, test helpers, fake implementations

// Dependency rule: Features → Shared packages only
// Shared packages do not depend on features

Each feature package defines its public API (a root view, a ViewModel factory, an AppRoute extension). The parent app module wires features together. Features are unaware of each other — they communicate through shared model objects or router actions.

When to modularize: When build times exceed 90 seconds clean, when two engineers regularly conflict on the same files, or when feature owners need to run isolated test targets. Don't split a small app into packages prematurely — the overhead adds friction before you hit the scale benefits.

Data layer contracts

The most brittle part of most SwiftUI apps is the data layer: SwiftData models bleeding into view state, @Model objects used directly in ViewModels, and no boundary between persistence and display representations.

// Separate persistence model from display model
// SwiftData @Model (persistence)
@Model
final class ItemRecord {
    var id: UUID
    var rawTitle: String
    var createdAt: Date
    // ...
}

// Display model (value type, derivation from record)
struct ItemDisplay: Identifiable {
    let id: UUID
    let title: String         // Formatted, trimmed
    let relativeDate: String  // "2 hours ago"
}

// Repository converts between layers
struct ItemRepository {
    func fetchDisplay(context: ModelContext) async -> [ItemDisplay] {
        let records = try context.fetch(FetchDescriptor<ItemRecord>())
        return records.map { r in
            ItemDisplay(id: r.id, title: r.rawTitle.trimmingCharacters(in: .whitespaces), relativeDate: r.createdAt.relativeDescription)
        }
    }
}

This separation makes ViewModels testable without a live SwiftData context, and prevents persistence schema migrations from cascading into display logic changes.

Rendering performance

SwiftUI re-evaluates views when observed state changes. Most performance issues come from one of four root causes:

Overly broad @Observable scope

An @Observable ViewModel with 20 published properties causes re-evaluation on any change. Split into focused sub-ViewModels or use @Observable's selective reading (views only subscribe to properties they read at rendering time).

Large view bodies

Views with 50+ lines of body code re-evaluate as a unit. Extract stable sub-components into separate structs with precisely scoped inputs. Components with only value-type inputs and no environment bindings re-render at minimum frequency.

Identity-breaking .id() calls in lists

Using .id(UUID()) on list rows creates and destroys views every render cycle. Stabilize identity: use the row model's own stable ID, not a dynamically generated one.

Expensive computations in body

Date formatting, string manipulation, and sorting inside body code run on every re-render. Pre-compute in the ViewModel and expose ready-to-render display values.

Testing strategy

SwiftUI view testing is fragile. Test at the ViewModel and repository layer instead:

// Use protocols + fake implementations for testable ViewModels
protocol FeedRepository: Sendable {
    func fetchFeed() async -> [FeedItem]
}

struct FakeFeedRepository: FeedRepository {
    let canned: [FeedItem]
    func fetchFeed() async -> [FeedItem] { canned }
}

// Test ViewModel state transitions directly
final class FeedViewModelTests: XCTestCase {
    func testLoadsItems() async {
        let vm = FeedViewModel(repository: FakeFeedRepository(canned: [.sample]))
        await vm.load()
        XCTAssertEqual(vm.items.count, 1)
        XCTAssertFalse(vm.isLoading)
    }

    func testEmptyState() async {
        let vm = FeedViewModel(repository: FakeFeedRepository(canned: []))
        await vm.load()
        XCTAssertTrue(vm.items.isEmpty)
    }
}

Reserve UI testing (XCUITest) for critical user paths (login, purchase, onboarding) where a single broken flow has high user impact. Unit test all ViewModel transitions and repository transforms.

FAQ

Should I use MVVM or TCA?

MVVM with @Observable is the idiomatic, zero-dependency default. TCA is appropriate for teams that need formal state machines and strict testable side-effect management. Don't adopt TCA because it's popular — adopt it when your team's testing requirements justify the overhead.

What replaces NavigationView?

NavigationStack (iOS 16+) for single-column flows. NavigationSplitView for two- or three-column layouts. Do not use NavigationView in new code — it is deprecated.

How do you modularize a SwiftUI app?

Use Swift Package Manager targets: one package per feature domain, plus shared UI, data, and networking packages. Features depend on shared packages only, never on each other.

Why does my SwiftUI view re-render too often?

Most common causes: overly broad @Observable scope, large view bodies that aren't split into focused components, identity-breaking .id(UUID()) in lists, and expensive computations running inside view body code.

SwiftUI architecture review for a live app?

The SwiftUI consulting service and iOS architecture audit apply these patterns to your existing codebase with a prioritized implementation plan.