Question 3
Dependency injection — constructors, containers, and test seams
The codebase uses a global ServiceLocator.shared singleton for networking and analytics. What problems does that cause, and how would you migrate toward something testable?
Follow-ups
- What about SwiftUI’s environment vs manual injection?
Answer outline
A global ServiceLocator.shared hides what a type really needs — code may look simple, but secretly depend on networking, analytics, persistence, or feature flags. The problems compound:
- 1.Hidden requirements — call sites cannot see what a type actually needs; the signature lies.
- 2.Test brittleness — tests must mutate shared global state, causing leakage and order-dependent failures.
- 3.Tight coupling — every caller is bound to one concrete implementation with no seam for substitution.
- 4.Concurrency hazards — mutable shared singletons are prone to data races under concurrent tests or multiple scenes.
The better default is dependency injection: pass dependencies in through an initializer so the type's requirements are explicit and easy to swap.
Migrate gradually: define protocols for key services, inject them in new or touched code, and push remaining service-locator calls up to the app's composition root.
In SwiftUI, use Environment for dependencies shared by a view subtree. Prefer initializer injection when a dependency should be obvious and replaceable in tests.
Principles
- Avoid hiding dependencies behind globals.
- Prefer initializer injection for clarity and testability.
- Use protocols when tests or multiple implementations need substitution.
- Move service lookup upward toward the app composition layer.
- Treat SwiftUI Environment as scoped injection, not a replacement singleton.
final class ProfileViewModel {
private let profileAPI: ProfileAPI
private let analytics: AnalyticsTracking
init(profileAPI: ProfileAPI, analytics: AnalyticsTracking) {
self.profileAPI = profileAPI
self.analytics = analytics
}
}