Protocol-Based DI
What Is Dependency Injection?
Dependency Injection (DI) is the practice of providing objects with their dependencies from outside, rather than having them create dependencies internally. This makes code loosely coupled and testable.
// Without DI - tightly coupled
class UserService {
func fetchUser() async throws -> User {
let apiClient = ApiClient() // hardcoded
return try await apiClient.request(.user)
}
}
// With DI - loosely coupled
class UserService {
private let apiClient: ApiClientProtocol
init(apiClient: ApiClientProtocol) {
self.apiClient = apiClient
}
func fetchUser() async throws -> User {
return try await apiClient.request(.user)
}
}
Protocol-Based Injection
Define dependencies as protocols, inject concrete implementations:
protocol AnalyticsServiceProtocol {
func track(event: String, properties: [String: Any])
func identify(userId: String, traits: [String: Any])
}
class MixpanelAnalytics: AnalyticsServiceProtocol {
func track(event: String, properties: [String: Any]) {
Mixpanel.sharedInstance().track(event: event, properties: properties)
}
func identify(userId: String, traits: [String: Any]) {
Mixpanel.sharedInstance().identify(userId)
}
}
class MockAnalytics: AnalyticsServiceProtocol {
var trackedEvents: [(String, [String: Any])] = []
func track(event: String, properties: [String: Any]) {
trackedEvents.append((event, properties))
}
func identify(userId: String, traits: [String: Any]) {}
}
Constructor Injection
The most explicit form - dependencies are required in the initializer:
@Observable
class CheckoutViewModel {
private let paymentService: PaymentServiceProtocol
private let cartService: CartServiceProtocol
private let analytics: AnalyticsServiceProtocol
init(
paymentService: PaymentServiceProtocol,
cartService: CartServiceProtocol,
analytics: AnalyticsServiceProtocol
) {
self.paymentService = paymentService
self.cartService = cartService
self.analytics = analytics
}
}
Constructor injection makes dependencies visible and testable. Every dependency is explicit.
Property Injection
For optional or contextual dependencies:
class MyViewController: UIViewController {
var analyticsService: AnalyticsServiceProtocol?
var themeProvider: ThemeProviderProtocol?
override func viewDidLoad() {
super.viewDidLoad()
analyticsService?.track(event: "screen_view", properties: [:])
}
}
Property injection is less safe (dependencies can be nil) but useful for optional services.
Environment Injection
Using SwiftUI Environment for DI
SwiftUI environment provides a clean way to inject dependencies without passing them through every view:
struct AnalyticsServiceKey: EnvironmentKey {
static let defaultValue: AnalyticsServiceProtocol = NoOpAnalytics()
}
extension EnvironmentValues {
var analytics: AnalyticsServiceProtocol {
get { self[AnalyticsServiceKey.self] }
set { self[AnalyticsServiceKey.self] = newValue }
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.analytics, MixpanelAnalytics())
}
}
}
struct BuyButton: View {
@Environment(\.analytics) var analytics
var body: some View {
Button("Buy") {
analytics.track(event: "purchase_tapped", properties: [:])
}
}
}
Environment with @Observable
For complex dependencies with state, use @Observable and .environment():
@Observable
class UserStore {
var currentUser: User?
var isAuthenticated = false
func login(email: String, password: String) async throws {
// login logic
}
}
@main
struct MyApp: App {
@State var userStore = UserStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(userStore)
}
}
}
struct ProfileView: View {
@Environment(UserStore.self) var userStore
var body: some View {
Text(userStore.currentUser?.name ?? "Guest")
}
}
Environment vs Constructor Injection
| Approach | Pros | Cons |
|---|---|---|
| Constructor | Explicit, visible | Requires passing through every level |
| Environment | Implicit, clean syntax | Hidden dependencies, harder to trace |
Use constructor injection for ViewModels with explicit dependencies. Use environment for cross-cutting concerns like analytics, theming, and logging.
Factory Patterns
Factory Protocol
A factory encapsulates object creation, hiding the complexity of dependency graphs:
protocol ViewModelFactoryProtocol {
func makeUserListViewModel() -> UserListViewModel
func makeUserProfileViewModel(userId: UUID) -> UserProfileViewModel
func makeSettingsViewModel() -> SettingsViewModel
}
class ViewModelFactory: ViewModelFactoryProtocol {
private let dependencies: AppDependencies
init(dependencies: AppDependencies) {
self.dependencies = dependencies
}
func makeUserListViewModel() -> UserListViewModel {
UserListViewModel(
fetchUsers: FetchUsersUseCase(repository: dependencies.userRepository),
analytics: dependencies.analytics
)
}
func makeUserProfileViewModel(userId: UUID) -> UserProfileViewModel {
UserProfileViewModel(
userId: userId,
fetchUser: FetchUserUseCase(repository: dependencies.userRepository),
updateUser: UpdateUserUseCase(repository: dependencies.userRepository)
)
}
func makeSettingsViewModel() -> SettingsViewModel {
SettingsViewModel(preferences: dependencies.preferences)
}
}
Environment-Based Factory
Inject the factory through the environment:
struct ViewModelFactoryKey: EnvironmentKey {
static let defaultValue: ViewModelFactoryProtocol = PreviewFactory()
}
extension EnvironmentValues {
var viewModelFactory: ViewModelFactoryProtocol {
get { self[ViewModelFactoryKey.self] }
set { self[ViewModelFactoryKey.self] = newValue }
}
}
// Views request VMs from the factory
struct UserListScreen: View {
@Environment(\.viewModelFactory) var factory
var body: some View {
UserListView(viewModel: factory.makeUserListViewModel())
}
}
Service Locator Pattern
A centralized registry that provides dependencies by type:
class ServiceLocator {
static let shared = ServiceLocator()
private var services: [String: Any] = [:]
func register<T>(_ service: T, for type: T.Type) {
let key = String(describing: type)
services[key] = service
}
func resolve<T>(_ type: T.Type) -> T? {
let key = String(describing: type)
return services[key] as? T
}
}
// Register
ServiceLocator.shared.register(ApiUserRepository(), for: UserRepository.self)
// Resolve
let repo = ServiceLocator.shared.resolve(UserRepository.self)
Choosing an Approach
| Pattern | Best For | Trade-offs |
|---|---|---|
| Constructor DI | ViewModels, Use Cases | Verbose but explicit |
| Environment DI | Cross-cutting concerns (analytics, theme) | Clean but hidden |
| Factory | Complex object creation | Extra abstraction layer |
| Service Locator | Small apps, quick prototyping | Hidden dependencies, harder to test |
DI Best Practices
- Prefer constructor injection for business logic (ViewModels, Use Cases)
- Use environment for truly global services (analytics, logging, theming)
- Define protocols for all external dependencies (API, database, storage)
- Provide default implementations for preview and testing contexts
- Keep the dependency graph shallow - avoid transitive dependencies
- Use factories when object creation involves complex setup
- Never use service locator in production apps - it hides dependencies
Quiz
1. What is dependency injection?
2. What is the most explicit form of dependency injection?
3. How do you inject dependencies via SwiftUI environment?
4. What is a ViewModel factory used for?
5. Why is the service locator pattern discouraged in production?
Flashcards
Question
What is dependency injection?
Click to reveal answer
Answer
Providing objects with their dependencies from outside instead of creating them internally.
Question
What is constructor injection?
Click to reveal answer
Answer
Requiring all dependencies in the initializer, making them explicit and mandatory.
Question
How do you inject via SwiftUI environment?
Click to reveal answer
Answer
Define EnvironmentKey with default value, extend EnvironmentValues, inject with .environment().
Question
What does a factory pattern do?
Click to reveal answer
Answer
Encapsulates complex object creation and dependency wiring behind a protocol.
Question
Why avoid service locator?
Click to reveal answer
Answer
It hides dependencies behind a global registry, making code harder to test and reason about.
Revision Notes
Key Takeaways
- 1. DI provides dependencies externally for loose coupling and testability
- 2. Constructor injection is the most explicit and recommended approach
- 3. SwiftUI environment enables clean dependency injection for cross-cutting concerns
- 4. Factory patterns encapsulate complex object creation and wiring
- 5. Avoid service locator pattern in production code
Interview Tips
- • Explain the difference between constructor, property, and environment injection
- • Know when to use environment vs constructor injection
- • Be ready to design a DI strategy for a given app architecture
- • Discuss why service locator is problematic for testing and maintainability
Cheat Sheet
DI: provide dependencies externally, not internally.
Constructor injection: explicit, preferred for ViewModels.
Environment injection: clean for cross-cutting concerns.
Factory: encapsulates complex object creation.
Service locator: discouraged in production.
Always define protocols for testability.