Most SwiftUI apps don’t fail because of UI.
They fail because of:
- tangled dependencies
- unclear ownership
- objects living too long
- services recreated too often
- global singletons everywhere
- impossible-to-test graphs
Dependency Injection alone does not solve this.
You need to design the dependency graph itself.
This post shows how to architect clear, predictable object graphs in SwiftUI:
- lifetimes
- scopes
- ownership
- boundaries
- and how everything fits together
This is the difference between “it works” and “it scales”.
🧠 The Core Principle
If you don’t design object lifetimes, SwiftUI will design them for you.
And you won’t like the result.
Every object must have:
- a clear owner
- a clear lifetime
- a clear scope
🧱 1. The Three Lifetimes You Must Model
Every dependency falls into one of these:
1. App Lifetime
- analytics
- feature flags
- auth session
- configuration
- logging
2. Feature Lifetime
- ViewModels
- repositories
- coordinators
- use cases
3. View Lifetime
- ephemeral helpers
- formatters
- local state
If you mix these, leaks and bugs appear.
🧭 2. The Dependency Graph Layers
Think in layers:
AppContainer
↓
FeatureContainer
↓
ViewModel
↓
View
Data flows down.
Ownership flows down.
Nothing flows back up.
🏗️ 3. App Container (Root of the Graph)
final class AppContainer {
let apiClient: APIClient
let authService: AuthService
let analytics: AnalyticsService
let featureFlags: FeatureFlagService
init() {
self.apiClient = APIClient()
self.authService = AuthService()
self.analytics = AnalyticsService()
self.featureFlags = FeatureFlagService()
}
}
This is:
- created once
- lives for the entire app
- injected downward
Never recreate this.
📦 4. Feature Containers (Scoped Lifetimes)
Each feature builds its own graph:
final class ProfileContainer {
let repository: ProfileRepository
let viewModel: ProfileViewModel
init(app: AppContainer) {
self.repository = ProfileRepository(api: app.apiClient)
self.viewModel = ProfileViewModel(repo: repository)
}
}
This container:
- is created when the feature appears
- is destroyed when the feature disappears
- owns its ViewModel
This gives you clean teardown.
🧩 5. ViewModels Do NOT Build Dependencies
Bad:
class ProfileViewModel {
let api = APIClient()
}
Good:
class ProfileViewModel {
let repo: ProfileRepository
init(repo: ProfileRepository) {
self.repo = repo
}
}
ViewModels consume, never construct.
🧬 6. Environment as Graph Injector (Not Storage)
Use environment to pass containers, not services.
.environment(\.profileContainer, container)
Then:
@Environment(\.profileContainer) var container
View gets:
- ViewModel
- dependencies
- without global state
🧱 7. Lifetime = Owner
If you can’t answer:
“Who deallocates this?”
You have a bug.
Examples:
- AppContainer → app lifetime
- FeatureContainer → navigation lifetime
- ViewModel → feature lifetime
- View → frame lifetime
Ownership must be visible in code.
🔄 8. Navigation Defines Object Lifetime
Navigation is not UI.
Navigation is memory management.
NavigationStack(path: $path) {
FeatureEntry()
}
When the feature leaves the stack:
- its container deallocates
- its ViewModel deallocates
- its subscriptions cancel
- its tasks stop
If that doesn’t happen, your graph is wrong.
🧠 9. Avoiding Singletons (Without Losing Convenience)
Instead of:
Analytics.shared.track()
Do:
analytics.track()
Where analytics is injected from the AppContainer.
You keep:
- global access
- testability
- control
Without global state.
🧪 10. Testing the Graph
Because everything is injected:
let mockRepo = MockProfileRepository()
let vm = ProfileViewModel(repo: mockRepo)
No:
- stubbing globals
- overriding singletons
- fighting the system
Your graph is your test harness.
❌ 11. Common Anti-Patterns
Avoid:
- building services in ViewModels
- global static singletons
- injecting everything everywhere
- feature containers that outlive navigation
- circular dependencies
- environment objects as service locators
These lead to:
- leaks
- bugs
- untestable code
- impossible refactors
🧠 Mental Model
Think like this:
Who creates it?
Who owns it?
Who destroys it?
If you can answer all three, your graph is healthy.
If you can’t, you have architectural debt.
🚀 Final Thoughts
A clean dependency graph gives you:
- predictable memory
- clean teardown
- easy testing
- safer refactors
- faster onboarding
- fewer production bugs
This is the backbone of:
- modular architecture
- multi-platform apps
- large teams
- long-lived codebases
Most SwiftUI issues at scale are not SwiftUI issues — they are graph design issues.
Top comments (0)