Mastering Type-Safe Dependency Injection in Swift using Property Wrappers
The Dependency Hell We All Face
We've all been there. You're working on a massive feature in a mature codebase, and you need to introduce a new service—maybe an APIService or a UserPreferencesStore. The naive approach is to pass this dependency through every single initializer in your view controller or view model hierarchy. It's called Dependency Injection (DI), and while it's fundamentally correct, the implementation often leads to "initializer bloat." You end up with a chain of initializers that exist solely to pass a single object from the App Delegate down to a deeply nested child view.
While manual DI is great for unit testing, it becomes a maintenance nightmare in large-scale Swift applications. On the flip side, using a massive, global ServiceLocator singleton is just as bad—it hides dependencies and makes your code harder to reason about. I spent a lot of time looking for a middle ground: something that feels local and explicit, but doesn't require passing objects through ten layers of boilerplate.
The Solution: Property Wrappers as Dependency Containers
A few years ago, I started leveraging Swift's Property Wrappers to build a lightweight, type-safe dependency injection system. This approach allows you to declare a dependency at the point of use, while the underlying mechanism handles the resolution. It keeps your initializers clean and makes your dependencies incredibly easy to swap out during unit testing.
Here is how I structure a production-ready version of this pattern.
// 1. Define a protocol for our container to ensure type safety
protocol DependencyContainer {
func resolve<T>() -> T
}
// 2. Create a central registry (The 'Resolver')
final class AppDependencyResolver: DependencyContainer {
static let shared = AppDependencyResolver()
// A dictionary to hold our factory closures
private var factories: [String: () -> Any] = [:]
private init() {}
// Register a type with a closure that returns the instance
func register<T>(type: T.Type, factory: @escaping () -> T) {
let key = String(describing: type)
factories[key] = factory
}
// Resolve the type from the registry
func resolve<T>() -> T {
let key = String(describing: T.self)
guard let factory = factories[key], let instance = factory() as? T else {
fatalError("No dependency registered for type: \(key)")
}
return instance
}
}
// 3. The Magic: The @Inject Property Wrapper
@propertyWrapper
struct Inject<T> {
private var dependency: T
init() {
// We resolve the dependency from our shared container during initialization
self.dependency = AppDependencyResolver.shared.resolve()
}
var wrappedValue: T {
get { dependency }
set { dependency = newValue }
}
}
Putting it into Practice
Imagine you are building a weather application. You have a WeatherService that fetches data from a remote API. Instead of passing this service through every view model, you can simply declare it.
// Protocols make testing possible
protocol WeatherServiceProtocol {
func fetchWeather() -> String
}
class RealWeatherService: WeatherServiceProtocol {
func fetchWeather() -> String { "Sunny, 25°C" }
}
// Your ViewModel stays clean and readable
class WeatherViewModel {
@Inject var weatherService: WeatherServiceProtocol
func updateUI() {
let weather = weatherService.fetchWeather()
print("Updating UI with: \(weather)")
}
}
// --- App Setup ---
let resolver = AppDependencyResolver.shared
resolver.register(type: WeatherServiceProtocol.self) { RealWeatherService() }
// Usage
let viewModel = WeatherViewModel()
viewModel.updateUI()
Why This Works (The Senior Dev Perspective)
You might be thinking, "Isn't this just a glorified Singleton?" There is a subtle but vital distinction here. While AppDependencyResolver.shared is a singleton, your business logic is not. Your WeatherViewModel doesn't know the resolver exists; it only knows it has a property that satisfies the WeatherServiceProtocol.
This provides several key advantages:
- Reduced Boilerplate: You avoid the "Prop Drilling" problem where you pass dependencies through components that don't even use them.
- Testability: This is the biggest win. In your unit tests, you can register a
MockWeatherServiceinto the resolver before initializing your ViewModel. Because the property wrapper resolves at initialization, the ViewModel will grab your mock instead of the real service. - Declarative Syntax: Looking at a class, you can immediately see its dependencies just by scanning for the
@Injectattribute. It serves as living documentation.
Pro-Tip: In a real production environment, I recommend adding atestResolveror a way to reset theAppDependencyResolverbetween test cases to ensure test isolation.
Caveats and Best Practices
While this pattern is powerful, don't over-apply it. If a class only has one dependency and it's only used in one place, a standard initializer is often simpler and more explicit. Use this pattern for cross-cutting concerns—things like networking layers, analytics engines, database managers, or user session handlers.
Also, be mindful of the lifecycle. The implementation I showed above creates a new instance every time resolve() is called (unless your factory closure returns a singleton). If you need your dependency to be a singleton, ensure your register closure returns a pre-instantiated object:
// Registering a singleton instead of a factory
let sharedService = RealWeatherService()
resolver.register(type: WeatherServiceProtocol.self) { sharedService }
By mastering this approach, you move away from the fragile "spaghetti" of manual passing and toward a modular, decoupled architecture that can actually scale with your team and your feature set.