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:
| Scope | Tool | When to use |
|---|---|---|
| View-local, simple | @State | Toggle state, text field value, scroll position |
| View-local, complex | @State + @Observable model | Multi-property view state, form editing |
| Parent → child | Binding / direct property | Component input, controlled input fields |
| Feature-wide | @Observable + environmentObject | Feature ViewModel accessed across a screen stack |
| App-wide | @Observable + .environment | Auth state, theme, routing coordinator |
| Persisted | SwiftData @Model | Any 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 featuresEach 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.
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.