DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Dependency Injection in SwiftUI (Best Practices)

SwiftUI apps grow fast — and without a clean dependency system, your code slowly becomes:

  • hard to test
  • impossible to mock
  • tightly coupled
  • full of singletons
  • difficult to preview

Dependency Injection (DI) solves all of this.

The best SwiftUI architecture uses simple, lightweight DI, not heavy frameworks.

This guide shows the production patterns I now use in every SwiftUI app:

  • service protocols
  • environment injection
  • dependency containers
  • preview injection
  • test replacement
  • feature-scoped DI
  • avoiding global singletons

Let’s make your app clean, scalable, and testable. 🚀


🧱 1. Start With Protocol-Based Services

Never depend directly on concrete services:

Wrong:

class ProfileViewModel {
    let api = APIService()  // tightly coupled
}
Enter fullscreen mode Exit fullscreen mode

✔️ Correct:

protocol UserServiceProtocol {
    func fetchUser() async throws -> User
}

final class UserService: UserServiceProtocol {
    func fetchUser() async throws -> User { ... }
}
Enter fullscreen mode Exit fullscreen mode

This immediately makes:

  • testing easier
  • previews accurate
  • code decoupled

🔌 2. Inject Services Into ViewModels

@Observable
class ProfileViewModel {
    let userService: UserServiceProtocol

    init(userService: UserServiceProtocol) {
        self.userService = userService
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

ProfileViewModel(userService: UserService())
Enter fullscreen mode Exit fullscreen mode

🌱 3. Add Environment-Based Dependency Injection

This is the cleanest pattern in SwiftUI.

Define an environment key:

private struct UserServiceKey: EnvironmentKey {
    static let defaultValue: UserServiceProtocol = UserService()
}

extension EnvironmentValues {
    var userService: UserServiceProtocol {
        get { self[UserServiceKey.self] }
        set { self[UserServiceKey.self] = newValue }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it in a View:

struct ProfileView: View {
    @Environment(\.userService) private var userService
}
Enter fullscreen mode Exit fullscreen mode

Override at the app root:

ContentView()
    .environment(\.userService, MockUserService())
Enter fullscreen mode Exit fullscreen mode

This enables:

  • preview overrides
  • test-time injection
  • feature-level swapping
  • simpler ViewModel creation

🧰 4. Lightweight Dependency Container (Optional but Powerful)

struct AppServices {
    let user = UserService()
    let analytics = AnalyticsService()
    let cache = ImageCache()
}
Enter fullscreen mode Exit fullscreen mode

Provide globally at app launch:

@main
struct MyApp: App {
    let services = AppServices()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(\.userService, services.user)
                .environment(\.analyticsService, services.analytics)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No singletons. No massive frameworks.


🧪 5. Dependency Injection in Tests

Override dependencies easily:

func test_profile_loads_mock_user() async {
    let mock = MockUserService(user: User(name: "Test"))
    let vm = ProfileViewModel(userService: mock)

    await vm.load()

    XCTAssertEqual(vm.user?.name, "Test")
}
Enter fullscreen mode Exit fullscreen mode

Mock service:

struct MockUserService: UserServiceProtocol {
    var user: User
    func fetchUser() async throws -> User { user }
}
Enter fullscreen mode Exit fullscreen mode

🖼 6. Dependency Injection in Previews

The #1 mistake with SwiftUI previews:

Using real API calls or real services.

Do this instead:

#Preview {
    ProfileView()
        .environment(\.userService, MockUserService(user: .preview))
}
Enter fullscreen mode Exit fullscreen mode

Previews become:

  • faster
  • offline
  • more predictable
  • safer to iterate on

🧩 7. Dependency Injection Inside Navigation

Avoid this anti-pattern:

NavigationLink {
    ProfileView(vm: ProfileViewModel(userService: UserService()))
}
Enter fullscreen mode Exit fullscreen mode

You lose testability & preview flexibility.

Instead, inject from above:

struct RootView: View {
    @Environment(\.userService) private var userService

    var body: some View {
        NavigationStack {
            HomeView()
                .navigationDestination(for: User.self) { user in
                    ProfileView(
                        vm: ProfileViewModel(userService: userService)
                    )
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Navigation automatically receives the correct dependency.


🔄 8. Feature-Scoped Dependency Injection

Each feature gets exactly the services it needs — nothing more.

Example:

Features/
│
├── Profile/
│   ├── ProfileView.swift
│   ├── ProfileViewModel.swift
│   ├── ProfileService.swift
│
├── Feed/
│   ├── FeedView.swift
│   ├── FeedViewModel.swift
│   ├── FeedService.swift
Enter fullscreen mode Exit fullscreen mode

This prevents “god objects” and giant app-wide containers.


⚡ 9. Prefer Composition Over Singleton Patterns

❌ Bad:

class API {
    static let shared = API()
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • hard to test
  • no swapping in previews
  • impossible to override
  • hidden dependencies

✔️ Good:

struct AppServices {
    let api: APIProtocol
}
Enter fullscreen mode Exit fullscreen mode

Dependency explicit, not hidden.


🏎 10. Clean DI Flow (Standard Pattern)

[Protocols]  [Concrete services]  [Environment injection]  [ViewModel injection]  [Views]
Enter fullscreen mode Exit fullscreen mode

This is the modern, scalable architecture for SwiftUI.

🚀 Final Thoughts

Dependency Injection is what transforms SwiftUI from:
❌ “pretty demos”

into:
✅ real, testable, scalable production apps.

With DI, you get:

  • faster previews
  • isolated tests
  • reusable ViewModels
  • decoupled architecture
  • reliable async operations
  • feature boundaries that scale

Top comments (0)