Introduction
Dependency Injection is a fundamental pattern in software architecture that helps manage dependencies between components, leading to modular, testable, and maintainable code. In this post, we’ll explore design considerations and implement essential components.
The two main functions of a DI system are registration and resolution:
- Registration defines which dependencies should be created and how they are instantiated.
- Resolution retrieves instances of these registered dependencies.
Registration
To register dependencies, we could store each instance in a dictionary, using its type as the key.
public final class Container {
private var services: [String: Any] = [:]
func register<T>(_ type: T.Type, _ instance: T) {
let key = String(describing: type)
services[key] = instance
}
}
But to conserve memory and improve startup time, we can delay instantiation until a dependency is actually needed. This is particularly beneficial for dependencies that are rarely used or expensive to initialize. Instead of directly storing instances, we store closures that build instances when called. This pattern is known as lazy instantiation.
func register<T>(_ type: T.Type, _ factory: @escaping () -> T) {
let key = String(describing: type)
services[key] = factory
}
Resolution
To resolve a dependency, we retrieve the registered closure from our dictionary and call it to create an instance. Since there’s no guarantee a dependency has been registered, and stored values are of type Any, we need to cast and handle errors accordingly.
We could choose to return an optional or throw an error, for demonstration purposes and since such issues are easy to identify during development, we will call fatalError if a requested dependency hasn’t been registered, this also keeps our resolution code clean and free from error-handling clutter.
public final class Container {
private var services: [String: Any] = [:]
public init() {}
public func register<T>(
_ type: T.Type,
_ factory: @escaping () -> T
) {
let key = String(describing: type)
services[key] = factory
}
public func resolve<T>(
_ type: T.Type
) -> T {
let key = String(describing: type)
guard
let service = services[key] as? () -> T
else {
fatalError("Service for type \(type) not found!")
}
return service()
}
}
Each time we resolve a dependency, a new instance is created. While this approach works well for many cases, it doesn’t cover all use cases. For example, singletons or shared resources might need to persist across resolutions. In future posts, we’ll discuss handling different dependency lifetimes, allowing more flexibility for shared or long-lived dependencies.
Using our DI container in an application
Now that we have our DI container set up, let’s see how we can use it in a simple application. We will create a couple of services and demonstrate how to register and resolve them using our container.
Step 1: Define some services
First, let’s define two services: DatabaseService
and UserService
. The DatabaseService
will simulate a database connection, while the UserService
will depend on DatabaseService
to fetch user information.
protocol DatabaseServiceProtocol {
func fetchUser() -> String
}
class DatabaseService: DatabaseServiceProtocol {
func fetchUser() -> String {
return "John Doe"
}
}
class UserService {
private let databaseService: DatabaseServiceProtocol
init(databaseService: DatabaseServiceProtocol) {
self.databaseService = databaseService
}
func getUser() -> String {
return databaseService.fetchUser()
}
}
Step 2: Register services in the DI Container
Next, we will register these services in our DI container. We will use closures to create instances of DatabaseService
and UserService
.
let container = Container()
container.register(DatabaseServiceProtocol.self) {
DatabaseService()
}
container.register(UserService.self) {
UserService(
databaseService: container.resolve(DatabaseServiceProtocol.self)
)
}
In the above example, you might have noticed that by capturing container
in the closure for UserService
we unintentionally create a retain cycle, container
holds a strong reference to the closure, and the closure holds a strong reference back to container
. To avoid this, we can modify the closure to accept a Container
as a parameter, eliminating the strong capture.
public func register<T>(
_ type: T.Type,
_ factory: @escaping (Container) -> T
) {
let key = String(describing: type)
services[key] = factory
}
public func resolve<T>(
_ type: T.Type
) -> T {
let key = String(describing: type)
guard
let service = services[key] as? (Container) -> T
else {
fatalError("Service for type \(type) not found!")
}
return service(self)
}
With this change, our registration now looks like this:
let container = Container()
container.register(DatabaseServiceProtocol.self) { _ in
DatabaseService()
}
container.register(UserService.self) { container in
UserService(databaseService: container.resolve(DatabaseServiceProtocol.self))
}
We could further conform Container
to a Resolver
protocol and use Resolver
as the argument type. This limits exposure to just the resolve method, keeping other methods hidden.
Step 3: Resolve and use the services
Now that we have registered our services, we can resolve and use them in our application.
let userService: UserService = container.resolve(UserService.self)
let user = userService.getUser()
...
Summary
This post demonstrated how to build a simple DI container in Swift, register dependencies, and resolve them in a straightforward way. In future posts, we’ll expand our DI container to support additional dependency lifetimes and more complex configurations.
Top comments (0)