Dependency injection is a powerful design pattern in Swift that helps to improve code flexibility, modularity and testability. At its simplest, it involves providing an object with all the resources it needs to function from an external source rather than creating them internally within the object. This approach improves flexibility by making it easier to replace or mock different dependencies without changing the object itself. We ‘inject’ different object in.
Dependency injection can be as simple as passing in a dependency through the initialiser like so:
class ViewModel {
let service: Service
init(service: Service = Service()) {
self.service = service
}
}
This works well for simple things but a nicer and more common approach is implementing a dependency container which is a centralised registry that provides and manages instances of the application’s dependencies. This post will show a basic and lightweight implementation of how a container could be created.
A Basic Implementation
To start, let’s define a protocol for what makes a dependency injection container. We need a way of registering and resolving dependencies.
protocol DIContainer {
func register<T>(_ type: T.Type, implementation: @escaping () -> T)
func resolve<T>(_ type: T.Type) -> T
func registerDependencies()
}
We can then make a very basic dependency container. We’ll ensure the container is a singleton so only once instance can provide dependencies, avoiding any strange bugs in the future. We will use a dictionary to store the services against strings.
final class DependencyContainer: DIContainer {
static let shared: DependencyContainer = .init()
private var services: [String: () -> Any] = [:]
private init() {
registerDependencies()
}
func register<T>(_ type: T.Type, implementation: @escaping () -> T) {
let stringKey = String(describing: type)
services[stringKey] = implementation
}
func resolve<T>(_ type: T.Type) -> T {
let stringKey = String(describing: type)
return services[stringKey]?() as! T
}
Note: We haven’t implemented the
registerDependencies()
method yet, we will do that in just a second.
Now we need a service to try it out.
protocol Service {
func printGreeting() -> String
}
class DefaultService: Service {
func printGreeting() -> String {
print("Hello!")
}
}
We can now register this dependency. I like to register all my dependencies in a separate file from the container logic using an extension.
extension DependencyContainer {
func registerDependencies() {
register(Service.self) { DefaultService() }
// ...
}
}
Finally we can inject our service into something like a view model. We can use the singleton instance of our dependency container to resolve all needed dependencies when the class is initialised.
class ViewModel {
private let service: Service
init(service: Service = DependencyContainer.shared.resolve(Service.self)) {
self.service = service
}
func example() {
service.printGreeting()
}
}
This is a great start and a nice basic container to house all of our application dependencies. However, there are still a few improvements to be made.
Using a Property Wrapper
Property wrappers allow us to annotate properties to give them extra functionality. We can utilise them to make resolving dependencies really easy and clean. It means we don’t have to manually call resolve for each of our dependencies in lots of initialisers in our application.
We will use generics so one property wrapper can be used no matter the service type.
@propertyWrapper
struct Injected<T> {
private var instance: T
init() {
instance = DependencyContainer.shared.resolve(T.self)
}
var wrappedValue: T {
get {
return instance
}
mutating set {
instance = newValue
}
}
}
Just this small modification now means we can resolve dependencies with one annotation so our view model now becomes this which is much nicer.
class ViewModel {
@Injected private let service: Service
func example() {
service.printGreeting()
}
}
Different Instance Types
Currently, our DI container creates a new instance of each service every time one is resolved. This may not be what you want as sometimes we need one or a shared instance of an object. So to improve this we will support:
- Transient: A new instance is created each time the dependency is resolved
- Singletons: One object is created and shared forever
- Shared objects: The same instance is used until no object references it, then it is deinitialised
To make this functionality possible, lets begin by creating an enum to represent each type of instance.
enum InstanceType {
case singleton, transient, shared
}
We can then update our DI container protocol to select an instance type when registering a dependency.
protocol DIContainer {
// ...
func register<T>(_ type: T.Type, instanceType: InstanceType, implementation: @escaping () -> T)
}
The final piece of setup we need to do is create a wrapper for weak references. This is because dictionaries don’t natively support weak storage. The wrapper is really simple.
class WeakWrapper<T: AnyObject> {
weak var value: T?
init(_ value: T?) {
self.value = value
}
}
So now we can start updating our DependencyContainer
. Firstly, we need three new dictionaries, one for storing the singleton instances, one for storing the weak instances and one for storing instance types.
private var singletons: [String: Any] = [:]
private var shared: [String: WeakWrapper<AnyObject>] = [:]
private var types: [String: InstanceType] = [:]
We can then update our registration method to default the instance type to transient and also save the type to the types dictionary.
func register<T>(_ type: T.Type, instanceType: InstanceType = .transient, implementation: @escaping () -> T) {
let stringKey = String(describing: type)
services[stringKey] = implementation
types[stringKey] = instanceType
}
Then we can update the resolve method to handle each type of instance. For singletons, an existing instance is fetched. If one doesn’t exist, it is created and stored. A similar thing happens for shared instances except the weak wrapper is used.
func resolve<T>(_ type: T.Type) -> T {
let stringKey = String(describing: type)
guard let instanceType = types[stringKey] else {
fatalError("Could not find the type of instance")
}
switch instanceType {
case .singleton:
if let singleton = singletons[stringKey] as? T {
return singleton
}
if let newInstance = services[stringKey]?() as? T {
singletons[stringKey] = newInstance
return newInstance
}
case .shared:
if let weakInstance = shared[stringKey]?.value as? T {
return weakInstance
}
if let newInstance = services[stringKey]?() as? T {
shared[stringKey] = WeakWrapper(newInstance as AnyObject)
return newInstance
}
case .transient:
return services[stringKey]?() as! T
}
fatalError("Could not resolve type")
}
Now when registering dependencies in our container, we can specify the instance type we need for our application.
extension DependencyContainer {
func registerDependencies() {
register(Service.self, instanceType: .shared) { RealService() }
// ...
}
}
Unit Testing
We need a way to swap out dependencies for mock versions when we are testing our application. To do that, all we need to do is register mock versions of our services and inject them when testing. Like so.
class MockService: Service {
func printGreeting() {
print("This is a test")
}
}
@Test func exampleTest() {
DependencyContainer.shared.register(Service.self) { MockService() }
// ...
}
This works because we use dictionaries to store our dependencies and a newer registered service will replace the existing one. If we wanted to be extra sure that mock services are being used, we could add a reset method to clear out all dependencies and call that before tests begin.
// DependencyContainer.swift
func reset() {
services.removeAll()
singletons.removeAll()
shared.removeAll()
types.removeAll()
}
// Tests
struct Tests {
init() {
DependencyContainer.shared.reset()
}
@Test func example() {
DependencyContainer.shared.register(Service.self) { MockService() }
// ...
}
}
Conclusion
This post showed a quick and easy way to centralise all your application’s dependency registration into one place. There are some more powerful and flexible solutions out there. I have been using Factory recently which is a great dependency and also comes with built in compile-time safety.
However if you just want a lightweight way to manage dependencies, it could be better to just create your own.
Thanks for reading.