Lambdaspire - Swift Dependency Resolution 📦
A lightweight IoC Container / Service Locator / Dependency Injection package for Swift.
📦 Lambdaspire - Swift Dependency Resolution
Some time ago I posted about an approach to dependency resolution using a (very basic) Service Locator implementation and a property wrapper @Resolved
for resolution inline with declarations. It was an early, relatively low-cost solution to dependency resolution begrudgingly adopted in lieu of any proper run-time reflection capabilities.
Though adequate run-time reflection capabilities are still missing, what we have now with attached macros in Swift opens up some related opportunities.
Namely, we can now implement more idiomatic inversion of control / dependency injection mechanisms that other run-times like .NET have long benefitted from.
I've published an updated version of my approach to dependency resolution in Swift that makes use of macros in the form of a Swift Package.
The readme is snapshotted below.
--
Lambdaspire Swift Dependency Resolution
A lightweight IoC Container / Service Locator / Dependency Injection package for Swift.
Inspired by Autofac and the modern .NET SDK.
If you're a .NET developer who's been struggling in the Apple developer ecosystem due to a lack of decent IoC, hopefully this package will help.
If you've never built with .NET (or any other platform) before but feel like your Swift code routinely becomes a spaghetti mess, difficult to test, or cumbersome to maintain, this package might help you wrangle complex dependency graphs and write more SOLID code.
Many Lambdaspire Swift packages will need some kind of dependency resolution that conforms to DependencyResolver
and DependencyRegistry
from the Abstractions package. The intent is for this package to satisfy that need.
Usage
High Level
Use ContainerBuilder
to register dependencies and build a Container
.
The ContainerBuilder
type conforms to DependencyRegistry
from the Abstractions package, and the Container
type conforms to the DependencyResolver
protocol. Ideally, use these abstractions in your code rather than the concrete container types.
Register dependencies as one of three lifetimes:
- Transient: A new instance every time the dependency is resolved.
- Scoped: A common instance every time the dependency is resolved in the same scope.
- Singleton: A globally common instance every time the dependency is resolved.
Use Container
to resolve dependencies as per the registrations.
let builder: ContainerBuilder = .init()
builder.transient(SomeStatelessService.self)
builder.scoped(SomeContextuallyStatefulService.self)
builder.singleton(SomeGloballyStatefulService.self)
let container = builder.build()
Check out Registration Methods for a comprehensive overview of registration.
Scopes
The DependencyResolutionScope
protocol defines means to resolve dependencies and to create a new scope.
The DependencyResolver
protocol extends this protocol, and therefore the Container
type conforms to it.
Dependencies registered as scoped
act as singletons within that scope.
let builder: ContainerBuilder = .init()
builder.scoped { TodoList() }
let container = builder.build()
let rootList: TodoList = container.resolve()
rootList.add("Demonstrate Scoped Dependencies")
rootList.print() // Will print one item.
let scope = container.scope()
let scopedList: TodoList = scope.resolve()
scopedList.print() // Will print zero items.
Registration as Self
The simplest way to register dependencies is as self. That is, if you have some concrete type OpenIdConnectAuthenticationService
and you want to resolve it as such, you can register and resolve like this:
let builder: ContainerBuilder = .init()
builder.transient(OpenIdConnectAuthenticationService.self)
let container = builder.build()
let openIdAuth: OpenIdConnectAuthenticationService = container.resolve()
While convenient, and in some cases totally fine, this violates the Dependency Inversion principle in SOLID which states that implementation details should depend on abstractions.
Registration as Abstraction
To register and resolve dependencies in a SOLID fashion, you can register concrete implementations behind abstractions and resolve via said abstractions. To do this with our authentication example above, we might introduce a protocol AuthenticationService
which OpenIdConnectAuthenticationService
conforms to. We would then depend on that protocol abstraction instead of the concrete class.
let builder: ContainerBuilder = .init()
builder.transient(AuthenticationService.self, assigned(OpenIdConnectAuthenticationService.self))
let container = builder.build()
let auth: AuthenticationService = container.resolve()
This means that, perhaps for testing purposes or if your authentication story changes, you can substitute in an alternative implementation.
// builder.transient(AuthenticationService.self, assigned(OpenIdConnectAuthenticationService.self))
builder.transient(AuthenticationService.self, assigned(MockAuthenticationService.self))
Abstractions needn't be protocols. The concrete type must simply inherit from / conform to the abstract type.
Registration Methods
The DependencyRegistry
protocol has 7 methods per registration type (transient, singleton, scoped):
func transient<I>(_ : @escaping () -> I)
func transient<I>(_ : @escaping (DependencyResolutionScope) -> I)
func transient<C>(_ : C.Type, _ : @escaping () -> C)
func transient<C>(_ : C.Type, _ : @escaping (DependencyResolutionScope) -> C)
func transient<C, I>(_ : C.Type, _ : Assigned<C, I>)
func transient<I: Resolvable>(_ : I.Type)
func transient<C, I: Resolvable>(_ : C.Type, _ : Assigned<C, I>)
// Repeat for singleton and scoped.
We'll cover each method, subbing in register
instead of transient
or the like.
1. Implementation Factory
Definition
func register<I>(_ : @escaping () -> I)
Registers an inferred type I
using a function returning that type.
Usage
builder.register { Implementation() }
// ...
let r: Implementation = container.resolve()
Note that the parameter is simply a function, so you could also use the type's static init
function if you prefer the parenthetical style over the closure braces:
builder.register(Implementation.init)
// ...
let r: Implementation = container.resolve()
2. Implementation Factory with Scope Parameter
Definition
func register<I>(_ : @escaping (DependencyResolutionScope) -> I)
Registers an inferred type I
using a function parameterised by the current scope.
Usage
builder.register { scope in
Implementation(dependency: scope.resolve())
}
// ...
let r: Implementation = container.resolve()
3. Contract Factory
Definition
func register<C>(_ : C.Type, _ : @escaping () -> C)
Registers a specified type C
using a function that may return any C
(typically a sub-type).
Usage
builder.register(Contract.self) { Implementation() }
// ...
let r: Contract = container.resolve()
Again, if you prefer the parenthetical style, you can do something like this (where applicable):
builder.register(Contract.self, Implementation.init)
// ...
let r: Contract = container.resolve()
4. Contract Factory with Scope Parameter
Definition
func register<C>(_ : C.Type, _ : @escaping (DependencyResolutionScope) -> C)
Registers a specified type C
using a function parameterised by the current scope.
Usage
builder.register(Contract.self) { scope in
Implementation(dependency: scope.resolve())
}
// ...
let r: Contract = container.resolve()
5. Contract as Implementation by Types
Definition
func register<C, I>(_ : C.Type, _ : Assigned<C, I>)
Registers a specified type C
against a specified, compatible type I
.
Usage
builder.register(Implementation.init)
builder.register(Contract.self, assigned(Implementation.self))
// ...
let r: Contract = container.resolve()
Note the unfortunate compiler hack, assigned(...)
, which enforces type compatibility.
Note also that, because Contract
is registered against the Implementation
type (rather than a method of instantiation), a method for instantiating Implementation
must also be registered. This need is mitigated by Resolvable
types and is demonstrated specifically in number 7.
6. Resolvable Implementation by Type
Definition
func register<I: Resolvable>(_ : I.Type)
Registers a specified type I
where I
conforms to Resolvable
.
The Resolvable
protocol and @Resolvable
macro are described in detail later.
Usage
@Resolvable
class ResolvableImplementation {
// ...
}
// ...
builder.register(ResolvableImplementation.self)
// ...
let r: ResolvableImplementation = container.resolve()
Note the use of the @Resolvable
attribute on the class declaration.
Note also the absence of a need to specify an instantiation method; the type can be registered merely as itself without specifying some function to produce an instance of it.
7. Contract as Resolvable Implementation by Types
Definition
func register<C, I: Resolvable>(_ : C.Type, _ : Assigned<C, I>)
Registers a specified type C
against a specified, compatible type I
where I
conforms to Resolvable
.
Usage
protocol Contract {
// ...
}
@Resolvable
class ResolvableImplementation : Contract {
// ...
}
// ...
builder.register(Contract.self, assigned(ResolvableImplementation.self))
// ...
let r: Contract = container.resolve()
About @Resolvable
The Resolvable
protocol is defined in Abstractions and simply defines an initializer that accepts a scope.
public protocol Resolvable {
init(scope: DependencyResolutionScope)
}
This package defines an accompanying macro, @Resolvable
, that can be applied to any type so that the required initializer can be auto-generated. This enables a more idiomatic style of cascading dependency resolution without manually declaring and maintaining initializers. This is particularly useful for complex dependency graphs.
Example
Not Ideal Approach
Imagine you have an authentication system implemented like below:
class AuthContext : ObservableObject {
@Published private(set) var user: Loadable<User> = .notLoaded
private let auth: AuthService
init(auth: AuthService) {
self.auth = auth
}
func signIn() {
user = .loading
Task {
do {
user = .loaded(try await auth.signIn())
} catch {
user = .failed(error)
}
}
}
}
protocol AuthService {
func signIn() async throws -> User
}
class OktaAuthService : AuthService {
init(config: OktaConfig) {
OktaLibrary.configure(config)
}
func signIn() async throws -> User {
OktaUserMapper.map(try await OktaLibrary.signIn())
}
}
To wire all this up as is in a Container
, we could do something like this:
let builder: ContainerBuilder = .init()
builder.singleton { scope in
AuthContext(auth: scope.resolve()
}
builder.transient(AuthService.self) { scope in
OktaAuthService(config: OktaConfig.fromBundle)
}
let container = builder.build()
It's okay, but the "Factory with Scope Parameter" methods are a verbose way to resolve complex dependency graphs (i.e. when dependencies have dependencies which have dependencies which have dependencies... etc). It also means the registration code requires ongoing maintenance as initializers change shape.
For example, if AuthContext
becomes dependent on additional services:
class AuthContext : ObservableObject {
// ...
private let analytics: AnalyticsService
private let cache: CacheService
// ...
}
Then the registration must be manually updated:
// ...
builder.singleton { scope in
AuthContext(
auth: scope.resolve(),
analytics: scope.resolve(),
cache: scope.resolve())
}
// ...
It's a simple change, but this quickly becomes tiresome as a codebase evolves, and ugly as the dependencies grow in number. It's clear from the repetition that there is room for automation.
More Ideal Approach
Instead, define your classes like this:
@Resolvable
class AuthContext : ObservableObject {
@Published private(set) var user: Loadable<User> = .notLoaded
private let auth: AuthService
init(auth: AuthService) {
self.auth = auth
}
func signIn() {
user = .loading
Task {
do {
user = .loaded(try await auth.signIn())
} catch {
user = .failed(error)
}
}
}
}
protocol AuthService {
func signIn() async throws -> User
}
@Resolvable
class OktaAuthService : AuthService {
init(config: OktaConfig) {
OktaLibrary.configure(config)
}
func signIn() async throws -> User {
OktaUserMapper.map(try await OktaLibrary.signIn())
}
}
Register the dependency graph like this:
let builder: ContainerBuilder = .init()
builder.singleton(AuthContext.self)
builder.transient(AuthService.self, assigned(OktaAuthService.self))
builder.transient(OktaConfig.fromBundle)
let container = builder.build()
This way, even as dependencies increase in number, our registrations need not change. It's more concise, easier to read, and requires much less maintenance as the codebase evolves.
Breaking Changes in v2.0.0
This version is vastly different from v1.#.#. Here is a list of the more significant changes:
ServiceLocator
has becomeContainer
.- Previously,
ServiceLocator
implemented bothDependencyRegistry
andDependencyResolver
. Now,ContainerBuilder
implementsDependencyRegistry
and produces aContainer
which implementsDependencyResolver
. - Due to the previous change, registrations cannot be updated in-flight. Instead of doing that, consider using a Provider pattern, and think of your IoC container as a configuration fixed for an application's lifetime.
FAQs
None yet. Got a question? Reach out.
Known Issues
None yet. Found a bug? Please create an issue and/or a pull request.
The End
👋 Happy resolving!