Introduction to DIve
DIve
is a minimalistic Swift dependency injection framework.
High level architecture
Pool
is an immutable storage whose elements are key-value pairs, the central part of the framework that is used to pass dependencies across objects. Pools can be filled out with dependencies manually or by using loaders.
Manual dependency loading
In the example below, we define a class RandomNumber
that depends on a random number generator. @Injected
property wrapper is used to access this dependency.
appending
function is used to add a SystemRandomNumberGenerator
instance, that conforms to RandomNumberGenerator
protocol, to an empty pool.
The dependency injection is performed by passing the pool to the object’s initializer.
import DIve
class RandomNumber: Injectee<Pool> {
@Injected private var generator: RandomNumberGenerator
func generate(in range: Range<UInt64>) -> UInt64 {
.random(in: range, using: &generator)
}
}
let pool = Pool()
.appending(SystemRandomNumberGenerator())
let randomNumber = RandomNumber(pool: pool)
print(randomNumber.generate(in: 0..<10))
Automatic dependency loading
The pool that we manually created in the previous example can be automatically created by loaders: SerialLoader
and ConcurrentLoader
objects.
let loader = SerialLoader(dependencies: [
.dependency(id: "RandomNumberGenerator", dependencies: []) { pool in
pool.appending(SystemRandomNumberGenerator())
}
])
let pool = try loader.load(Pool())
let loader = ConcurrentLoader(dependencies: [
.dependency(id: "RandomNumberGenerator", dependencies: []) { pool in
pool.appending(SystemRandomNumberGenerator())
}
])
let pool = try await loader.load(Pool())
Dependency graph
Loaders can automatically fill out pools with objects by resolving and loading dependency graphs. A dependency graph
is a directed graph that represents relationships between entities, where nodes represent entities and edges represent dependencies between them. For instance, in this graph below, GreetingProviding
entity depends on StringsProviding
and RandomNumberGenerator
.
Entities are Swift objects: protocols, classes, structures, etc.
protocol GreetingProviding {
func greeting() -> String
}
class GreetingProvider: GreetingProviding {
/* the protocol implementation */
}
protocol StringsProviding {
var count: Int { get }
func string(at index: Int) -> String
}
struct StringsProvider: StringsProviding {
/* the protocol implementation */
}
GreetingProvider
generates a random index using RandomNumberGenerator
object and returns a greeting string by this index from strings provided by StringsProvider
. Please see the latest Swift documentation for the details about RandomNumberGenerator
and SystemRandomNumberGenerator
.
Graph representation
The graph can be represented in the code as a set of Dependency
objects. Each dependency has a unique identifier and a list of its dependencies. Closures inside dependency objects append actual instances to the pool.
extension Set where Element == AsyncInjectingDependency<Pool> {
static let dependencies: Self = [
.dependency(id: .randomNumberGenerator, dependencies: []) { pool in
pool.appending(SystemRandomNumberGenerator())
},
.dependency(id: .stringsProviding, dependencies: []) { pool in
pool.appending(StringsProvider(strings: [
/* <an array of greetings> */
]))
},
.dependency( id: .greetingProviding, dependencies: [
.randomNumberGenerator, .stringsProviding
]) { pool in
pool.appending(GreetingProviderFactory(pool: pool).make())
}
]
}
Unique identifiers
The unique identifiers are defined as an extension of DependencyId
object.
extension DependencyId {
static let randomNumberGenerator: Self = "RandomNumberGenerator"
static let stringsProviding: Self = "StringsProviding"
static let greetingProviding: Self = "GreetingProviding"
}
Using factories
To simplify complex objects creation and in the sake of separation of concerns, factories can be used inside the closures. For instance, GreetingProviderFactory
is used to create GreetingProvider
.
private class GreetingProviderFactory: Injectee<Pool> {
@Injected private var generator: RandomNumberGenerator
@Injected private var stringsProvider: StringsProviding
func make() -> GreetingProviding {
GreetingProvider(
generator: generator,
stringsProvider: stringsProvider
)
}
}
Loading progress handler
Loading complex dependency graphs takes time. A loading event handler can be passed to the concurrent loader to track the loading progress and update the loading user interface accordingly.
actor LoadingEventHandler: ConcurrentLoadingEventHandling {
func willLoadDependency(_ dependencyId: DependencyId) async {
/* update UI, save data for analytics, etc. */
}
func didLoadDependency(_ dependencyId: DependencyId) async {
/* update UI, save data for analytics, etc. */
}
}
Loading dependency graph
Using the ConcurrentLoader
along with the LoadingEventHandler
to load the previously defined dependency graph.
let loader = ConcurrentLoader(dependencies: .dependencies)
let handler = LoadingEventHandler()
let pool = try await loader.load(Pool(), handler)
Fatal errors handling
Accessing an object that is not presented in a pool leads to a fatal error. The default fatal error handler calls fatalError(_:file:line:)
function that immediately terminates the app. It's possible to use a custom fatal errors handler to do extra action before the app termination.
struct FatalErrorHandler: FatalErrorHandling {
func fatalErrorHandler(_ error: Error) -> Never {
print("A fatal error occurred: \(error)")
fatalError()
}
}
let pool = Pool()
.appending(FatalErrorHandler())