A lightweight, experimental, type-safe dependency injection container (cough) for Swift
Belum allows defining dependencies between frameworks / modules in Swift at runtime.
Pictures say more than a thousand words. Imagine you have a social media app with the following frameworks (please excuse the German):
In a traditional project, your dependency tree would look like this
With a dependency injection container, your dependencies will be injected at runtime into the container. Your invididual frameworks only know about the container - not about all the frameworks. Functionality is defined via protocols. That way, your dependencies are much cleaner, none of your frameworks know about other frameworks. Everything is hidden behind protocols. In addition, there're no cyclical dependencies because the frameworks don't import each other.
I'm glad you asked! Yes, there're, and they're much more production-ready than this solution. If I piqued your interest, head over to Dip by my good friend Olivier Halligon.
Compared to other solutions, the one used here is very minimal (which means easy to understand) and utilizes type-safety for a very comfortable container usage experience.
Really. However, it was also written as an example for a talk I gave at MyPoster in Munich. Nevertheless, the ideas outlined in this repository are sound - though probably not production ready ;-)
In comparison to other containers, the major idea in this solution is to have a pre-generated set of generic struct
types mapping to the amount of possible dependencies. That way, keypaths can be used to access dependencies with their specific type. Code says more than a thousand words, so here we go.
We need one type that contains all the dependencies. However, only some of them are exposed to the other frameworks:
public struct Dependencies {
var chat: ChatProtocol!
var home: HomeProtocol!
var settings: SettingsProtocol!
public init() {}
}
A framework / module defines its dependencies via a type-alias:
typealias ChatDependencies = Dependencies2<
Dependencies,
HomeProtocol,
SettingsProtocol
>
Here's we tell Swift that the ChatDependencies
are based on the Dependencies
struct, and take the HomeProtocol
and the SettingsProtocol
. Nothing more. So that Swift knows that our Chat
module is only allowed to access the Home and Settings. Nothing else
Next up, we need the obligatory protocol
and an initializer for our Chat:
protocol ChatProtocol {
var openChats: Int { get }
}
final class ChatController: ChatProtocol {
// Conformance to the ChatProtocol
var number: Int {
42
}
// Required initializer with our dependencies
public let dependencies: ChatDependencies
public init(_ dependencies: ChatDependencies) {
self.dependencies = dependencies
}
}
If you imagine that have the same setup for our Settings and Home (see below), then we can initialize the dependencies at runtime via calling the setup in our App / Main target:
// Create a belum for the given dependencies
let belum = Belum(Dependencies())
// Add reverse dependencies
belum.setup { registrar in
// Register the lazy closure for the creation of the ChatController
// `$0` is the runtime-constructed dependency container (e.g. `ChatController.Dependencies2`)
registrar.register(
{ ChatController($0) },
// Which keypath in `Dependencies` to store this in
for: \.chat,
// The keypaths for the dependencies we're allowed to access
dependencies: \.home, \.settings)
}
Here's a full example:
Imagine an app with three dependencies, chat
, home
, settings
public struct Dependencies {
var chat: ChatProtocol!
var home: HomeProtocol!
var settings: SettingsProtocol!
public init() {}
}
protocol SettingsProtocol {}
typealias settingsDependencies = Dependencies1<
Dependencies,
HomeProtocol
>
final class SettingsController: SettingsProtocol {
public let dependencies: SettingsDependencies
public init(_ dependencies: SettingsDependencies) {
self.dependencies = dependencies
}
}
protocol ChatProtocol {
var openChats: Int { get }
}
typealias ChatDependencies = Dependencies2<
Dependencies,
HomeProtocol,
SettingsProtocol
>
final class ChatController: ChatProtocol {
var number: Int {
42
}
public let dependencies: ChatDependencies
public init(_ dependencies: ChatDependencies) {
self.dependencies = dependencies
}
}
protocol HomeProtocol {}
typealias HomeDependencies = Dependencies1<
Dependencies,
ChatProtocol
>
final class HomeController: HomeProtocol {
public let dependencies: HomeDependencies
public init(_ dependencies: HomeDependencies) {
self.dependencies = dependencies
}
func openChats() -> String {
guard let chats = dependencies.chat?.openChats() else { return "" }
"\(chats)"
}
}
let belum = Belum(Dependencies())
// Add reverse dependencies
belum.setup { registrar in
registrar.register(
{ ChatController($0) },
for: \.chat,
dependencies: \.home, \.settings)
registrar.register(
{ SettingsController($0) },
for: \.settings,
dependencies: \.home)
registrar.register(
{ HomeController($0) },
for: \.home,
dependencies: \.chat)
}
In addition to the Dependencies2
struct in:
typealias ChatDependencies = Dependencies2<
Dependencies,
HomeProtocol,
SettingsProtocol
>
There're also Dependencies3
, Dependencies4
, etc. These are auto-generated via the generate.swift
script in the root.
Because apart from the huge chunk of generated code in the Dependencies.swift, the main functionality in Belum.swift is only ~70 lines of code. The generated code, on the other hand, is generated from a template of around ~20 lines of code. So the whole container (if we ignore repeated code generation) is defined in ~100 lines of code.
Mainly because of threading reasons. Currently, in Belum.swift, we're lazily constructing the dependencies, so that they're only constructed when they're first needed. However, we don't know from which threads the developer constructs which dependencies. Dictionary access in Swift is not concurrency-safe, so we need a mutex or a queue to make sure only one dependency is constructed at a time.
Currently, this code uses a Dispatch queue, but that is problematic as some dependencies will want to be created on the main queue. We can't create them on the main queue though, because we need a blocking / sync queue (because we require a return result), and blocking main
when coming from main
will result in a race condition. The sanest solution would be to not construct these dependencies lazily, but that is also so much less fun.
In reality, this project is mostly aimed as an experiment to showcase the idea. I'd rather not be the owner of a dependency injection library that is used in production level apps. I don't have the time for that. So, proceed with caution.
License, MIT