The lightweight library for dependency injection in Swift
- iOS/iPadOS 13.0+, macOS 10.15+, watchOS 6.0+, tvOS 13.0+
- Xcode 11+
- Swift 5.3+
You can install the library with Swift Package Manager. Once you have your Swift package set up, adding Dependency Injection as a dependency is as easy as adding it to the dependencies
value of your Package.swift
.
dependencies: [
.package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "1.0.0"))
]
If you are new to the concept of Dependency Injection, you can check Wikipedia for a general introduction and brief overview.
A container is a key component of Dependency Injection. A container manages dependencies of your codebase. First, you register your dependencies within the container identified by either their types, or protocols or classes they conform to or inherit from respectively. Then, you use the container to get (i.e. resolve) instances of the registered dependencies. The class Container represents the Dependency Injection container.
Other terminology that might be useful:
- Factory - A function or closure instantiating a dependency
- Scope - A scope of a registered dependency can be either
new
orshared
. When a dependency is registered withnew
scope, a new instance of the dependency is created each time the dependency is resolved from the container. When a dependency is registered withshared
scope, a new instance of the dependency is created only the first time it is resolved from the container. The created instance is cached and it is returned for all upcoming resolution requests, i.e. it is a singleton - Registration with an argument - All dependencies must be initialized and their initializers often have parameters. Typically, the objects that are passed as the input parameters are resolved from the same container. But you might want to have a registered dependency which requires a parameter in its initializer that can't be registered in the container. In such case, you register the dependency with a variable argument and you specify a value of the argument when the dependency is being resolved; the value is passed as an input parameter to the dependency factory.
A dependency is registered with the register
method of the container. A dependency is registered with a type that is either its own type, or a protocol or a class that the dependency conforms to or inherits from respectively. Next, a scope of registration must be specified (see the terminology above). Finally, a factory closure or function that returns an instance of the dependency must be provided.
It can look like this:
let container = Container()
container.register(type: Dependency.self, in: .shared) { container in
Dependency(
manager: container.resolve(type: Manager.self)
)
}
We can also use the fact that the type is by default inferred from the factory return type and shared
is the default scope so we can simplify the above snippet into this:
let container = Container()
container.register { container in
Dependency(
manager: container.resolve(type: Manager.self)
)
}
Moreover, if we want to register a shared dependency that has no sub-dependencies from the container we can use an overloaded registration method with an autoclosure like this:
let container = Container()
container.register(dependency: SimpleDependency())
See the terminology above to understand what we mean by the registration with an argument.
DISCUSSION: The registration with an argument doesn't have any scope parameter and it is for a reason. The container should always return a new instance for dependencies with arguments as the behaviour for resolving shared instances with arguments is undefined. Should the argument conform to Equatable
to compare the arguments to tell whether a shared instance with a given argument was already resolved? Shared instances are typically not dependent on variable input parameters by definition.
Let's assume that our dependency from above needs also an integer that is determined right before the dependency is supposed to be resolved from the container. There is no point in registering the integer as a dependency in the container, moreover, we typically don't even want to register simple types like integers. For such case, we have the registration with an argument:
let container = Container()
container.register { container, number in
Dependency(
integer: number,
manager: container.resolve(type: Manager.self)
)
}
Let's have look at an example from above:
let container = Container()
container.register { container in
Dependency(
manager: container.resolve(type: Manager.self)
)
}
In the factory closure, we typically just call the dependency initializer and we resolve its input parameters from the container. You can get rid of this duplicated boiler-plate by using autoregister
method where you specify just the initializer that should be used to initialize the dependency, instead of writing the same factories over and over again. The above example then looks like this:
let container = Container()
container.autoregister(initializer: Dependency.init)
Similarly, we can use autoregistration with an argument and replace this:
let container = Container()
container.register { container, number in
Dependency(
integer: number,
manager: container.resolve(type: Manager.self)
)
}
With the following:
let container = Container()
container.autoregister(argument: Int.self, initializer: Dependency.init)
Dependency resolution is very straightforward. You can use either the container's tryResolve
method that throws an error when something goes wrong, or simply resolve
which returns the resolved non-optional dependency but if anything goes wrong, your app will crash.
You can resolve a registered dependency like this:
let container = Container()
container.register { container in
Dependency(
manager: container.resolve(type: Manager.self)
)
}
let dependency = container.resolve(type: Dependency.self)
let dependency2: Dependency = container.resolve()
Or a dependency registered with an argument like this:
let container = Container()
container.register { container, number in
Dependency(
integer: number,
manager: container.resolve(type: Manager.self)
)
}
let dependency = container.resolve(type: Dependency.self, argument: 42)
let dependency2: Dependency = container.resolve(argument: 42)
The package contains also two convenient property wrappers @Injected
and @LazyInjected
. As long as you are fine with using the Container.shared
or any other static container instance, you can use the following syntactic sugar to resolve dependencies:
class Singletons {
static let container = Container()
static func configure() {
container.autoregister(initializer: Dependency.init)
}
}
class Object {
@Injected(from: Singletons.container) var dependency: Dependency
}
Or if you use the Container.shared
singleton, then you can write simply:
class Object {
@Injected var dependency: Dependency
}
When using the @Injected
property wrapper, the dependency is resolved right in the moment when the property is instantiated. If you prefer to resolve the dependency only when it is accessed for the first time, you should rather use @LazyInjected
:
let container = Container()
container.autoregister(initializer: Dependency.init)
class Object {
@LazyInjected(from: container) var dependency: Dependency
// Resolve from `Container.shared`
@LazyInjected var dependency2: Dependency
func doStuff() {
dependency.doStuff()
dependency2.doStuff()
}
}
In the example above the dependencies aren't resolved immediately when an instance of Object
is initialized but only when the doStuff
method is called for the first time.
- Register and resolve a shared instance
- Register and resolve a new instance
- Register an instance with an identifier
- Register an instance with an argument
- Convenient property wrapper
- Autoregister
- SPM package
- Register an instance with multiple arguments
- Container hierarchy
- Thread-safety
- Detect circular dependencies