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
}
✔️ Correct:
protocol UserServiceProtocol {
func fetchUser() async throws -> User
}
final class UserService: UserServiceProtocol {
func fetchUser() async throws -> User { ... }
}
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
}
}
Usage:
ProfileViewModel(userService: UserService())
🌱 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 }
}
}
Use it in a View:
struct ProfileView: View {
@Environment(\.userService) private var userService
}
Override at the app root:
ContentView()
.environment(\.userService, MockUserService())
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()
}
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)
}
}
}
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")
}
Mock service:
struct MockUserService: UserServiceProtocol {
var user: User
func fetchUser() async throws -> User { user }
}
🖼 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))
}
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()))
}
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)
)
}
}
}
}
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
This prevents “god objects” and giant app-wide containers.
⚡ 9. Prefer Composition Over Singleton Patterns
❌ Bad:
class API {
static let shared = API()
}
Problems:
- hard to test
- no swapping in previews
- impossible to override
- hidden dependencies
✔️ Good:
struct AppServices {
let api: APIProtocol
}
Dependency explicit, not hidden.
🏎 10. Clean DI Flow (Standard Pattern)
[Protocols] ⟶ [Concrete services] ⟶ [Environment injection] ⟶ [ViewModel injection] ⟶ [Views]
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)