Creating a simple dependency injection framework in Swift [Part 4]: Dynamic arguments
Hugo Granja
Posted on November 6, 2024
Introduction
Some objects need parameters only available at runtime. For example, a UserService might require a userId that's known only after a user logs in. To handle such cases we should support dynamic injection when resolving instances.
Registration
Using Swift's parameter packs, we can let our factory closures specify any arguments needed when registering services.
final class Service<T, each A> {
let lifetime: Lifetime
let factory: (Container, repeat each A) -> T
init(
_ lifetime: Lifetime,
_ factory: @escaping (Container, repeat each A) -> T
) {
self.lifetime = lifetime
self.factory = factory
}
}
public func register<T, each A>(
_ type: T.Type,
lifetime: Lifetime = .transient,
_ factory: @escaping (Container, repeat each A) -> T
) {
let key = String(describing: type)
let service = Service(lifetime, factory)
services[key] = service
}
Resolution
Upon resolution we expect to be given the same arguments that were previously specified on registration to create an instance of that type.
public func resolve<T, each A>(
_ type: T.Type,
arguments: repeat each A
) throws -> T {
let key = String(describing: type)
guard
let service = services[key] as? Service<T, repeat each A>
else {
fatalError("[DI] Service for type \(type) not found!")
}
switch service.lifetime {
case .transient:
return service.factory(self, repeat each arguments)
...
}
}
Usage
Assuming userId is a string, here’s how to register and resolve services:
let container = Container()
container.autoRegister(
DatabaseService.self,
using: DatabaseServiceImpl()
)
container.register(UserService.self) { container, userId in
UserServiceImpl(
databaseService: container.resolve(DatabaseService.self),
userId: userId
)
}
container.resolve(UserService.self, arguments: "1234")
To reduce coupling, classes requiring UserService should not directly reference the container. Instead, inject a closure to create UserService, as demonstrated below with UserJourneyFactoryImpl.
final class UserJourneyFactoryImpl: UserJourneyFactory {
private let userServiceFactory: (String) -> UserService
init(
userServiceFactory: @escaping (String) -> UserService
) {
self.userServiceFactory = userServiceFactoryViewFactory
}
func makeUserService(userId: String) -> UserService {
return userServiceFactory(userId)
}
}
container.register(UserJourneyFactory.self) { r in
UserJourneyFactoryImpl(
userServiceFactory: { userId in
r.resolve(UserService.self, arguments: userId)
}
)
}
This setup allows, for example, a Coordinator to use UserJourneyFactory to create user-specific journeys in the app. Alternatively, you could inject the factory closure directly into the Coordinator.
Explore the code
Check out the full implementation of this library on BackpackDI on GitHub.
For a practical example, see MobileUseCases on GitHub.
I'd recommend looking at the entry point of the app MUCApp.swift
and AppContainer.swift
inside the DependencyInjection folder for a demonstration on how to centralize the configuration of the container and establish a composition root.
That’s it for this guide for now!
Let me know if you’d like to see other features or examples in the comments.
Posted on November 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.