How to write self injecting code?
Hey there, I hope you’re having a wonderful day :)
In this article, we will demonstrate on how to minimize the amount of work needed to setup class instance definition and dependency injection inside your iOS project.
I will assume you already know what dependency injection is and why is it an integral part of a testable architecture, but if not, I highly recommend taking a look at various sources in order to understand it before continuing.
#1 Define classes and dependencies
In this example, we will create a simple shopping app where a user is able to select items from a list of products, add them to a shopping cart and then perform a purchase. This project will consist of 4 main components:
- A service for performing mocked API calls
- A repository for providing the data
- Use cases for fetching items and buying
- UI presenting the data to the user
Notice how we first started with the service. This is because the service will be at the top of our dependency graph. In other words, our service will have no dependencies on its own, but components bellow it will have a dependency on a component above it.
In a real world however, a service would probably also have a dependency on some network provider for performing the API calls, but we will keep this example as simple as possible, by having mocked data provided by the service.
So let’s define our Service protocol and class implementation:
import RxSwift
protocol ShopService {
func getProducts() -> Single<[Product]>
func purchaseProducts(from shoppingCart: ShoppingCart) -> Single<Bool>
}
final class ShopServiceImpl: ShopService {
func getProducts() -> Single<[Product]> {
return .just(Product.allCases)
}
func purchaseProducts(from shoppingCard: ShoppingCart) -> Single<Bool> {
return .just(true)
}
}
Our service is pretty simple and straightforward. We will keep the implementation at a minimum, since that part is irrelevant for the topic of dependency injection.
Next we will define the Repository which has a dependency on our service:
import RxSwift
protocol ShopRepository {
func getProducts() -> Single<[Product]>
func purchaseProducts(from shoppingCart: ShoppingCart) -> Single<Bool>
}
final class ShopRepositoryImpl: ShopRepository {
private let service: ShopService
init(service: ShopService) {
self.service = service
}
func getProducts() -> Single<[Product]> {
return service.getProducts()
}
func purchaseProducts(from shoppingCart: ShoppingCart) -> Single<Bool> {
return service.purchaseProducts(from: shoppingCart)
}
}
Our repository almost seems redundant, because it just calls the service methods. But that is fine, since it keeps our architecture consistent and clean. In a real life scenario, a repository would probably also perform some data mapping from API resource format to a domain format and/or save data to a local storage. Anyway, back to business.
Let’s define our use cases:
import RxSwift
protocol GetProductsUseCase {
func execute() -> Single<[Product]>
}
final class GetProductsUseCaseImpl: GetProductsUseCase {
private let repository: ShopRepository
init(repository: ShopRepository) {
self.repository = repository
}
func execute() -> Single<[Product]> {
return repository.getProducts()
}
}
import RxSwift
protocol PurchaseProductsUseCase {
func execute(shoppingCart: ShoppingCart) -> Single<Bool>
}
final class PurchaseProductsUseCaseImpl: PurchaseProductsUseCase {
private let repository: ShopRepository
init(repository: ShopRepository) {
self.repository = repository
}
func execute(shoppingCart: ShoppingCart) -> Single<Bool> {
return self.repository.purchaseProducts(from: shoppingCart)
}
}
And lastly, our view model to which we will bind our view:
import RxCocoa
class ShopVM {
// Dependencies
private let getProductsUseCase: GetProductsUseCase
private let purchaseProductsUseCase: PurchaseProductsUseCase
private let mapper: ShopViewMapper
// Stored data
private var selectedProducts: SelectedProducts = []
init(getProductsUseCase: GetProductsUseCase,
purchaseProductsUseCase: PurchaseProductsUseCase,
mapper: ShopViewMapper) {
self.getProductsUseCase = getProductsUseCase
self.purchaseProductsUseCase = purchaseProductsUseCase
self.mapper = mapper
}
}
extension ShopVM: ViewModelType {
typealias SelectedProducts = Set<Product>
struct Input {
let selectProduct: Driver<Product>
let purchase: Driver<Void>
}
struct Output {
let productList: Driver<[Product]>
let selectedProducts: Driver<SelectedProducts>
let totalPrice: Driver<String>
}
func transform(input: Input) -> Output {
let productList = self.getProductsUseCase.execute()
.asDriver(onErrorJustReturn: [])
let selectedProducts = input.selectProduct
.map { [unowned self] product -> SelectedProducts in
// If product is already added to the shopping cart, tapping it again will remove it from the list
if self.selectedProducts.contains(product) {
self.selectedProducts.remove(product)
} else {
self.selectedProducts.insert(product)
}
return self.selectedProducts
}
let purchaseResult = input.purchase
.withLatestFrom(selectedProducts)
.asObservable()
.map(mapper.mapShoppingCart)
.flatMapLatest(purchaseProductsUseCase.execute)
.map { [weak self] success -> SelectedProducts in
guard let self = self else {
return []
}
if success {
self.selectedProducts.removeAll()
}
return self.selectedProducts
}
.asDriver(onErrorJustReturn: [])
let selectedProductsMerge = Driver.merge(selectedProducts, purchaseResult)
let totalPrice = selectedProductsMerge
.map(mapper.mapTotalPrice)
return .init(
productList: productList,
selectedProducts: selectedProductsMerge,
totalPrice: totalPrice
)
}
}
import Foundation
protocol ShopViewMapper {
func mapShoppingCart(from selectedProducts: ShopVM.SelectedProducts) -> ShoppingCart
func mapTotalPrice(from selectedProducts: ShopVM.SelectedProducts) -> String
}
final class ShopViewMapperImpl: ShopViewMapper {
func mapShoppingCart(from selectedProducts: ShopVM.SelectedProducts) -> ShoppingCart {
//let products = selectedProducts.map { $1 }
return .init(
id: UUID().uuidString,
products: Array(selectedProducts)
)
}
func mapTotalPrice(from selectedProducts: ShopVM.SelectedProducts) -> String {
let price = selectedProducts.reduce(0) { $0 + $1.pricePerKg * $1.averageWeight }
let formattedPrice = price.formatPrice ?? ""
return "Total price: \(formattedPrice)"
}
}
Our final dependency graph looks like this:
Components that are in the same row are part of the same architectural layer. Our ShopVC is the view, but we don’t care about its implementation, so we have omitted it from the example. You can find the full implementation in the git repo link bellow. Notice how each of our implementation classes ends with a “Impl” suffix after the protocol name. This will be very important later on.
#2: Resolving class instances
Now that we have defined our classes with their corresponding dependencies, how do we instantiate them?
First of, we want our ShopRepository and ShopService to be shared centralized data providers (singletons). A common way to achieve this is by defining a shared instance like this:
extension ShopServiceImpl {
static let shared: ShopService = ShopServiceImpl()
}
extension ShopRepositoryImpl {
static let shared: ShopRepository = ShopRepositoryImpl(service: ShopServiceImpl.shared)
}
We can already see several problems with this approach:
- A developer may not know which implementation to use as a dependency to some other class. He might create an instance manually without knowing there is a shared instance that should be used instead.
- A developer shouldn’t care how to instantiate some class. He should be provided an already created instance with its dependencies ready to be used, if possible.
- We are manually defining shared instances for each of our singletons, which is tedious work and creates boiler plate code. For big projects which has tons of singletons, this also affects maintainability.
Ignoring these problems for now, let’s see how would we instantiate our ShopVM view model:
let shopRepository: ShopRepository = ShopRepositoryImpl.shared
let getProductsUseCase: GetProductsUseCase = GetProductsUseCaseImpl(repository: shopRepository)
let purchaseProductsUseCase: PurchaseProductsUseCase = PurchaseProductsUseCaseImpl(repository: shopRepository)
let shopMapper: ShopViewMapper = ShopViewMapperImpl()
let shopViewModel = ShopVM(getProductsUseCase: getProductsUseCase,
purchaseProductsUseCase: purchaseProductsUseCase,
mapper: shopMapper)
Now we can see a lot more problems. Every time we need a new instance of the view model, we need to instantiate all of its non-singleton dependencies as well. Luckily, we can use a dependency injection framework to provide us with the instances, without caring about their dependencies. One of the most popular DI frameworks for Swift is Swinject due to being very lightweight and easy-to-use. Using Swinject, we can register our protocol implementations and dependencies as follows:
import Swinject
enum SingletonContainer {
static let instance: Container = {
let container = Container(defaultObjectScope: .container)
container.register(ShopService.self) { _ in
ShopServiceImpl()
}
container.register(ShopRepository.self) {
ShopRepositoryImpl(service: $0.resolve(ShopService.self)!)
}
return container
}()
}
enum InstanceContainer {
static let instance: Container = {
let container = Container(parent: SingletonContainer.instance, defaultObjectScope: .transient)
container.register(GetProductsUseCase.self) {
GetProductsUseCaseImpl(repository: $0.resolve(ShopRepository.self)!)
}
container.register(PurchaseProductsUseCase.self) {
PurchaseProductsUseCaseImpl(repository: $0.resolve(ShopRepository.self)!)
}
container.register(ShopViewMapper.self) { _ in
ShopViewMapperImpl()
}
container.register(ShopVM.self) {
ShopVM(getProductsUseCase: $0.resolve(GetProductsUseCase.self)!,
purchaseProductsUseCase: $0.resolve(PurchaseProductsUseCase.self)!,
mapper: $0.resolve(ShopViewMapper.self)!)
}
return container
}()
}
This is much better, because we can now retrieve a new instance of the ShopVM without caring about its dependencies by just calling:
let shopViewModel = InstanceContainer.instance.resolve(ShopVM.self)!
And we no longer need the shared instances for ShopRepository and ShopService singletons, since they are now registered in a shared singleton container.
This is a huge improvement, however we still have to write boiler plate code for registering our instances to the containers. Also, if we add/remove/change a dependency in the class’ constructor, we also have to update the code in the container it was registered in. For big projects, the container would grow huge and become hard to maintain. We should delegate writing this boiler plate code to the compiler, using a code generator like Sourcery .
#3 Writing self injecting code
For starters, let’s add a generic type extension to Swinject ’s Resolver resolve() method, so we don’t have to explicitly specify a type when resolving an instance:
import Swinject
extension Resolver {
func resolve<T>(type: T.Type = T.self) -> T {
guard let instance = self.resolve(T.self) else {
fatalError("Implementation for type \(T.self) not registered to \(self).")
}
return instance
}
}
We can now resolve an instance by simply calling .resolve() if type can be inferred. This will make it slightly easier to write the code generation script.
Next, integrate Sourcery into the project. I will assume you already know what Sourcery is and how to use it by following the documentation. In short, it’s a code generator for Swift which saves your from hassle of writing boiler plate or repetitive code by letting the compiler do it for you. If you aren’t already using code generators in your projects, you should definitely start.
With that out of the way, let’s write a Sourcery template file that will generate containers for registering our types and performing dependency injections. We will then add a build phase run script that will execute Sourcery and generate our code whenever we build the project.
But before it can do that, we need to tell Sourcery which types we want to be injectable and which types we want as singletons. So let’s define two blank protocols: Injectable and Singleton.
protocol Injectable {}
protocol Singleton {}
Then make all our types conform to Injectable type. On top of that, make our ShopRepository and ShopService conform to Singleton type. Our ShopService protocol now looks like this:
protocol ShopService: Injectable, Singleton {
func getProducts() -> Single<[Product]>
func purchaseProducts(from shoppingCart: ShoppingCart) -> Single<Bool>
}
Now the fun part. We will write a Sourcery template file which will scan our code for Injectable and Singleton types, register them in respective containers and then resolve their implementation class. So PurchaseProductsUseCase will be resolved as PurchaseProductsUseCaseImpl. The script will then scan constructor parameters of the implementation class for any Injectable type and perform constructor injections. And if it finds a non-injectable type in the constructor parameters, it will throw a compiler error.
In other words, we want Sourcery to scan this:
protocol GetProductsUseCase: Injectable {
func execute() -> Single<[Product]>
}
…to generate this:
container.register(GetProductsUseCase.self) {
GetProductsUseCaseImpl(
repository: $0.resolve()
)
}
Seems easy enough. So let’s start by writing a macro that will generate constructor injection code:
{% macro injectType type %}
{% if type.initializers.count == 0 %}
{{ type.name }}()
{% else %}
{% for initializer in type.initializers %}
{{ type.name }}(
{% for parameter in initializer.parameters %}
{% if parameter.type.based.Injectable %}
{{ parameter.name }}: resolver.resolve(){% if not forloop.last%}, {% endif %}
{% else %}
#error("Cannot inject non-injectable dependency '{{ parameter.name }}' of type '{{ parameter.unwrappedTypeName }}'")
{% endif %}
{% endfor %}
)
{% endfor %}
{% endif %}
{% endmacro %}
There is a lot going on here, so let’s go step by step:
- We give this macro a name so we can reuse it from several places
- Then we check whether this type’s constructor has any initializers; if not then we just call the init method
- If the constructor has initializers, we iterate through them and check whether each initializer is of type Injectable, otherwise place a compiler error in place
- call .resolve() method for each parameter to resolve and inject its instance
- We need to append a comma after each parameter until the last one, to avoid syntax issues
If you call this macro for ShopVM for example, you would get this:
ShopVM(
getProductsUseCase: resolver.resolve(),
purchaseProductsUseCase: resolver.resolve(),
mapper: resolver.resolve()
)
Works like magic! That’s why it’s called Sourcery :)
This code won’t compile, of course, because we are missing a reference to the resolver. So let’s continue by adding stencil code for registering our ShopVM class to the container:
{% macro registerClass type %}
// MARK: {{ type.name }}
container.register({{ type.name }}.self) { resolver in
{% call injectType type %}
}
{% endmacro %}
Running our template script now will give us this:
// MARK: ShopVM
container.register(ShopVM.self) { resolver in
ShopVM(
getProductsUseCase: resolver.resolve(),
purchaseProductsUseCase: resolver.resolve(),
mapper: resolver.resolve()
)
}
There is one last piece of the puzzle missing: the container itself.
So let’s expand our template and add the stencil script for generating our Instance and Singleton containers:
/// Provides singletons
enum SingletonContainer {
static let instance: Container = {
let container = Container(defaultObjectScope: .container)
{% for type in types.protocols where type.based.Injectable and type.based.Singleton %}
{% call registerProtocol type %}
{% endfor %}
return container
}()
}
/// Provides new instances
enum InstanceContainer {
static let instance: Container = {
let container = Container(parent: SingletonContainer.instance, defaultObjectScope: .transient)
{% for type in types.protocols where type.based.Injectable and not type.based.Singleton %}
{% call registerProtocol type %}
{% endfor %}
{% for type in types.classes where type.based.Injectable and not type.implements.Singleton %}
{% for inheritedType in type.inheritedTypes %}
{% if inheritedType == "Injectable" %}
{% call registerClass type %}
{% endif %}
{% endfor %}
{% endfor %}
return container
}()
}
Notice that when registering classes we need to make sure only classes that directly implement Injectable type are included, because classes like GetProductsUseCaseImpl are already registered via registerProtocol macro. Which we are missing still, so let’s add it:
{% macro registerProtocol type %}
// MARK: {{ type.name }}
container.register({{ type.name }}.self) { resolver in
{% for impl in types.implementing[type.name] where impl.name|contains:"Impl" %}
{% call injectType impl %}
{% endfor %}
}
{% endmacro %}
As you can see, this simply works by iterating through all types which implement the current protocol and contain “Impl” in their name. This will make sure we can have as many classes conform to the same protocol as we want in our project, but only one will be registered to and provided by the container.
Our final stencil template for generating dependency injection code looks like this:
import Swinject
{% macro injectType type %}
{% if type.initializers.count == 0 %}
{{ type.name }}()
{% else %}
{% for initializer in type.initializers %}
{{ type.name }}(
{% for parameter in initializer.parameters %}
{% if parameter.type.based.Injectable %}
{{ parameter.name }}: resolver.resolve(){% if not forloop.last%}, {% endif %}
{% else %}
#error("Cannot inject non-injectable dependency '{{ parameter.name }}' of type '{{ parameter.unwrappedTypeName }}'")
{% endif %}
{% endfor %}
)
{% endfor %}
{% endif %}
{% endmacro %}
{% macro registerProtocol type %}
// MARK: {{ type.name }}
container.register({{ type.name }}.self) { resolver in
{% for impl in types.implementing[type.name] where impl.name|contains:"Impl" %}
{% call injectType impl %}
{% endfor %}
}
{% endmacro %}
{% macro registerClass type %}
// MARK: {{ type.name }}
container.register({{ type.name }}.self) { resolver in
{% call injectType type %}
}
{% endmacro %}
/// Provides singletons
enum SingletonContainer {
static let instance: Container = {
let container = Container(defaultObjectScope: .container)
{% for type in types.protocols where type.based.Injectable and type.based.Singleton %}
{% call registerProtocol type %}
{% endfor %}
return container
}()
}
/// Provides new instances
enum InstanceContainer {
static let instance: Container = {
let container = Container(parent: SingletonContainer.instance, defaultObjectScope: .transient)
{% for type in types.protocols where type.based.Injectable and not type.based.Singleton %}
{% call registerProtocol type %}
{% endfor %}
{% for type in types.classes where type.based.Injectable and not type.implements.Singleton %}
{% for inheritedType in type.inheritedTypes %}
{% if inheritedType == "Injectable" %}
{% call registerClass type %}
{% endif %}
{% endfor %}
{% endfor %}
return container
}()
}
Running the script will generate the following code:
// Generated using Sourcery 1.3.4 — https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
import Swinject
/// Provides singletons
enum SingletonContainer {
static let instance: Container = {
let container = Container(defaultObjectScope: .container)
// MARK: ShopRepository
container.register(ShopRepository.self) { resolver in
ShopRepositoryImpl(
service: resolver.resolve()
)
}
// MARK: ShopService
container.register(ShopService.self) { resolver in
ShopServiceImpl()
}
return container
}()
}
/// Provides new instances
enum InstanceContainer {
static let instance: Container = {
let container = Container(parent: SingletonContainer.instance, defaultObjectScope: .transient)
// MARK: GetProductsUseCase
container.register(GetProductsUseCase.self) { resolver in
GetProductsUseCaseImpl(
repository: resolver.resolve()
)
}
// MARK: PurchaseProductsUseCase
container.register(PurchaseProductsUseCase.self) { resolver in
PurchaseProductsUseCaseImpl(
repository: resolver.resolve()
)
}
// MARK: ShopViewMapper
container.register(ShopViewMapper.self) { resolver in
ShopViewMapperImpl()
}
// MARK: ShopVM
container.register(ShopVM.self) { resolver in
ShopVM(
getProductsUseCase: resolver.resolve(),
purchaseProductsUseCase: resolver.resolve(),
mapper: resolver.resolve()
)
}
return container
}()
}
And that’s it. You no longer ever need to worry about managing your instances, dependencies and singletons. You just add a dependency to the initializer of a Injectable class and let Sourcery do the rest. Not only did we add a template for letting Sourcery generate dependency injections for us, but we also learned how to write our own stencil templates.
Hope this was helpful! You can find the full demo project in the GitHub repo below:
dinocata/dependency-injection-demo
Top comments (0)