DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Dependency Graph Architecture (Object Lifetimes & Scope)

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Good:

class ProfileViewModel {
    let repo: ProfileRepository

    init(repo: ProfileRepository) {
        self.repo = repo
    }
}
Enter fullscreen mode Exit fullscreen mode

ViewModels consume, never construct.


🧬 6. Environment as Graph Injector (Not Storage)

Use environment to pass containers, not services.

.environment(\.profileContainer, container)
Enter fullscreen mode Exit fullscreen mode

Then:

@Environment(\.profileContainer) var container
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Do:

analytics.track()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)