Dependency Injection — Expert Decisions
Expert decision frameworks for dependency injection choices. Claude knows DI basics — this skill provides judgment calls for when and how to apply DI patterns.
Decision Trees
Do You Need DI?
Is the dependency tested independently? ├─ NO → Is it a pure function or value type? │ ├─ YES → No DI needed (just call it) │ └─ NO → Consider DI for future testability │ └─ YES → How many classes use this dependency? ├─ 1 class → Simple constructor injection ├─ 2-5 classes → Protocol + constructor injection └─ Many classes → Consider lightweight container
The trap: DI everything. If a helper function has no side effects and doesn't need mocking, don't wrap it in a protocol.
Which Injection Pattern?
Who creates the object? ├─ Caller provides dependency │ └─ Constructor Injection (most common) │ init(service: ServiceProtocol) │ ├─ Object creates dependency but needs flexibility │ └─ Default Parameter Injection │ init(service: ServiceProtocol = Service()) │ ├─ Dependency changes during lifetime │ └─ Property Injection (rare, avoid if possible) │ var service: ServiceProtocol? │ └─ Factory creates object with dependencies └─ Factory Pattern container.makeUserViewModel()
Protocol vs Concrete Type
Will this dependency be mocked in tests? ├─ YES → Protocol │ └─ NO → Is it from external module? ├─ YES → Protocol (wrap for decoupling) └─ NO → Is interface likely to change? ├─ YES → Protocol └─ NO → Concrete type is fine
Rule of thumb: Network, database, analytics, external APIs → Protocol. Date formatters, math utilities → Concrete.
DI Container Complexity
Team size? ├─ Solo/Small (1-3) │ └─ Default parameters + simple factory │ ├─ Medium (4-10) │ └─ Simple manual container │ final class Container { │ lazy var userService = UserService() │ } │ └─ Large (10+) └─ Consider Swinject or similar (only if manual wiring becomes painful)
NEVER Do
Protocol Design
NEVER create protocols with only one implementation:
// ❌ Protocol just for the sake of it protocol DateFormatterProtocol { func format(_ date: Date) -> String }
class DateFormatterImpl: DateFormatterProtocol { func format(_ date: Date) -> String { ... } }
// ✅ Just use the type directly let formatter = DateFormatter() formatter.dateStyle = .medium
Exception: When wrapping external dependencies for decoupling or testing.
NEVER mirror the entire class interface in a protocol:
// ❌ 1:1 mapping is a code smell protocol UserServiceProtocol { var users: [User] { get } var isLoading: Bool { get } func fetchUser(id: String) async throws -> User func updateUser(_ user: User) async throws func deleteUser(id: String) async throws // ...20 more methods }
// ✅ Minimal interface for what's actually needed protocol UserFetching { func fetchUser(id: String) async throws -> User }
NEVER put mutable state requirements in protocols:
// ❌ Forces implementation details protocol CacheProtocol { var storage: [String: Any] { get set } // Leaks implementation }
// ✅ Behavior-focused protocol CacheProtocol { func get(key: String) -> Any? func set(key: String, value: Any) }
Constructor Injection
NEVER use property injection when constructor injection works:
// ❌ Object can be in invalid state class UserViewModel { var userService: UserServiceProtocol! // Can be nil!
func loadUser() async {
let user = try? await userService.fetchUser(id: "1") // Crash if not set!
}
}
// ✅ Guaranteed valid state class UserViewModel { private let userService: UserServiceProtocol
init(userService: UserServiceProtocol) {
self.userService = userService // Never nil
}
}
NEVER create objects with many dependencies (> 5):
// ❌ Too many dependencies — class does too much init( userService: UserServiceProtocol, authService: AuthServiceProtocol, analyticsService: AnalyticsProtocol, networkManager: NetworkManagerProtocol, cacheManager: CacheProtocol, configService: ConfigServiceProtocol, featureFlagService: FeatureFlagProtocol ) { ... }
// ✅ Split into smaller, focused classes // Or create a composite service
Service Locator (Anti-Pattern)
NEVER use Service Locator pattern:
// ❌ Hidden dependencies, runtime errors, untestable class UserViewModel { func loadUser() async { let service = ServiceLocator.shared.resolve(UserServiceProtocol.self)! // Crashes if not registered // Dependency is hidden // Can't see what this class needs } }
// ✅ Explicit constructor injection class UserViewModel { private let userService: UserServiceProtocol // Visible dependency
init(userService: UserServiceProtocol) {
self.userService = userService
}
}
Testing
NEVER create mocks with real side effects:
// ❌ Mock does real work class MockNetworkManager: NetworkManagerProtocol { func request<T>(_ endpoint: Endpoint) async throws -> T { // Actually makes network call! return try await URLSession.shared.data(from: endpoint.url) } }
// ✅ Mocks return stubbed data class MockNetworkManager: NetworkManagerProtocol { var stubbedResult: Any? var stubbedError: Error?
func request<T>(_ endpoint: Endpoint) async throws -> T {
if let error = stubbedError { throw error }
return stubbedResult as! T
}
}
NEVER test mocks instead of real code:
// ❌ Testing the mock, not the system func testMockReturnsUser() { let mock = MockUserService() mock.stubbedUser = User(name: "John") XCTAssertEqual(mock.fetchUser().name, "John") // Tests mock, not app }
// ✅ Test the system under test func testViewModelLoadsUser() async { let mock = MockUserService() mock.stubbedUser = User(name: "John")
let viewModel = UserViewModel(userService: mock) // SUT
await viewModel.loadUser(id: "1")
XCTAssertEqual(viewModel.user?.name, "John") // Tests ViewModel
}
Essential Patterns
Default Parameter Injection
// Production uses real, tests inject mock @MainActor final class UserViewModel: ObservableObject { private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
}
}
// Production let viewModel = UserViewModel()
// Test let viewModel = UserViewModel(userService: MockUserService())
Simple Manual Container
@MainActor final class Container { static let shared = Container()
// Singletons (lazy initialized)
lazy var networkManager: NetworkManagerProtocol = NetworkManager()
lazy var authService: AuthServiceProtocol = AuthService(network: networkManager)
lazy var userService: UserServiceProtocol = UserService(network: networkManager)
// Factory methods (new instance each time)
func makeUserViewModel() -> UserViewModel {
UserViewModel(userService: userService)
}
func makeLoginViewModel() -> LoginViewModel {
LoginViewModel(authService: authService)
}
}
SwiftUI Environment Injection
// Custom 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 } } }
// Inject at app level @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() .environment(.userService, Container.shared.userService) } } }
// Consume in any view struct UserView: View { @Environment(.userService) var userService
var body: some View {
// Use userService
}
}
Mock with Verification
final class MockUserService: UserServiceProtocol { // Stubbed returns var stubbedUser: User? var stubbedError: Error?
// Call tracking
private(set) var fetchUserCallCount = 0
private(set) var fetchUserLastId: String?
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
fetchUserLastId = id
if let error = stubbedError { throw error }
guard let user = stubbedUser else {
throw MockError.notStubbed
}
return user
}
}
// Test with verification func testFetchesCorrectUser() async { let mock = MockUserService() mock.stubbedUser = User(id: "123", name: "John")
let viewModel = UserViewModel(userService: mock)
await viewModel.loadUser(id: "123")
XCTAssertEqual(mock.fetchUserCallCount, 1)
XCTAssertEqual(mock.fetchUserLastId, "123")
}
Quick Reference
When to Use Each Pattern
Pattern Use When Avoid When
Constructor injection Default choice Never avoid
Default parameters Convenience with testability Dependency changes at runtime
Property injection Framework requires it (rare) You have control over init
Factory Object needs runtime parameters Simple object creation
Container Many cross-cutting dependencies Small app, few dependencies
Protocol Checklist
-
Will it be mocked? If no, skip protocol
-
Interface is minimal (only needed methods)
-
No mutable state requirements
-
No implementation details leaked
-
Single responsibility
DI Red Flags
Smell Problem Fix
Protocol for every class Over-engineering Only where needed
Service Locator Hidden dependencies Constructor injection
5 constructor params Class does too much Split responsibilities
Property injection Object can be invalid Constructor injection
Mock does real work Tests are slow/flaky Return stubbed data
1:1 protocol:class ratio Unnecessary abstraction Remove unused protocols
SwiftUI DI Comparison
Pattern Scope Use For
@Environment
View hierarchy System/app services
@EnvironmentObject
View hierarchy Observable shared state
@StateObject init injection Single view View-specific ViewModel
Container factory App-wide Complex dependency graphs