Zero-cost dependency injection using Swift Macros
- Implements zero-cost dependency injection via Swift Macros
@AutoRegister
callsresolve()
on all the factory's dependencies@SingletonRegister
follows the same principle as@AutoRegister
, but storing (and exposing) the generated value in a singleton@FactoryRegister
exposes all parameters to theresolve(...)
method
- Type safe
- Generates vanilla Swift
- Requires Swift 5.9
Presently, the following limitations exist (due to compiler bug):
- The hub type used to register dependencies must either be a
class
or astruct
; usingenum
s will result in the compiler failing - All dependencies must be registered in the same file where the hub type is declared (they can be split into multiple
extension
s for organization though)
- Introduces factories for registered types
- Soft deprecates
@FactoryRegister(_:parameterTypes:using:)
in favour of@Register(_:parameterTypes:using:)
- Soft deprecates
@FactoryRegister(_:parameterTypes:factory:)
in favour of@Register(_:parameterTypes:factory:)
- Soft deprecates
@OpaqueFactoryRegister(_:parameterTypes:using:)
in favour of@OpaqueRegister(_:parameterTypes:using:)
- Soft deprecates
@OpaqueFactoryRegister(_:parameterTypes:factory:)
in favour of@OpaqueRegister(_:parameterTypes:factory:)
- Resolves a bug in
DIFactoryAutoRegistration
where the shorthandresolve
method failed to use the mock as it used the factory directly
- Restores factory for "naked" types, treating all stated dependencies as explicit.
- Fully deprecates
@FactoryRegister(_:parameterTypes:factory:)
in favour of@FactoryRegister(_:parameterTypes:using:)
(which allows mixing explict parameters with auto-resolved dependencies) - Adopts named parameters when it is possible to retrieve them from the factory definition (e.g. when a method name is used such as
SomeTime.init(resolvableDependencyA:resolvableDependencyB:parameter1:parameter2)
)
- Add full support for registered opaque types to interplay with non-opaque registrations
- Soft deprecation of
@FactoryRegister(_:parameterTypes:factory:)
- Adds support for registering dependencies into the assembly using opaque types
- Allows factories to be registered using mixed types: auto-resolved and parametric
- Remove sample client from products
- Fix
getPlainTypeName
, which was failing to extract simple types
- Improve mock functions to take parameters in factories
- Add mocking
Define some type that will serve both as the "assembly hub" and the resolution entry point.
(As stated in Limitations, enum
s are not supported).
enum Dependency { }
On this example, assemblies will be defined in extensions of Dependency
(although this is not mandatory and can be done directly on the type declaration).
In the following example, we will assume the following types:
protocol ABTestingProtocol { }
protocol CodeGuardsProtocol { }
protocol ThemeProtocol { }
final class ABTesting: ABTestingProtocol { }
final class CodeGuards: CodeGuardsProtocol { }
final class Theme: ThemeProtocol {
private let abTesting: ABTestingProtocol
private let codeGuards: CodeGuardsProtocol
init(
abTesting: ABTestingProtocol,
codeGuards: CodeGuardsProtocol
) {
self.abTesting = abTesting
self.codeGuards = codeGuards
}
}
And then create an assembly to register them:
import MDI
@SingletonRegister((any ABTestingProtocol).self, using: ABTesting.init)
@SingletonRegister((any CodeGuardsProtocol).self, using: CodeGuards.init)
@SingletonRegister((any ThemeProtocol).self, parameterTypes: (any ABTestingProtocol).self, (any CodeGuardsProtocol).self, using: Theme.init(abTesting:codeGuards:))
extension Dependency { }
@SingletonRegister
will call resolve
on both ABTestingProtocol
and CodeGuardsProtocol
.
Since both are declared in the assembly (mind they could easily be declared elsewhere) this succeeds; otherwise we'd get a compiler error.
Note that, in the previous example, all dependencies were singletons, but this obviously did not have to be the case.
If instead @AutoRegister
was used:
import MDI
@AutoRegister((any ABTestingProtocol).self, using: ABTesting.init)
@AutoRegister((any CodeGuardsProtocol).self, using: CodeGuards.init)
@AutoRegister((any ThemeProtocol).self, parameterTypes: (any ABTestingProtocol).self, (any CodeGuardsProtocol).self, using: Theme.init(abTesting:codeGuards:))
extension Dependency { }
New instances of the registered types would be created on each call to resolve(...)
.
Finally, some dependencies require parameters that cannot be resolved, but rather passed when instancing.
This can easily be achieved via @FactoryRegister
.
In the following example, we can resolve ThemeProtocol
, but not necessarily boot: Date
or sessionId: String
.
protocol AppContextProtocol {}
final class AppContext: AppContextProtocol {
let boot: Date
let sessionId: String
let theme: ThemeProtocol
init(
boot: Date,
sessionId: String,
theme: ThemeProtocol
) {
self.boot = boot
self.sessionId = sessionId
self.theme = theme
}
}
Using @FactoryRegister
we can expose the required parameters while even leveraging resolve(...)
in the factory method to resolve ThemeProtocol
:
import MDI
@FactoryRegister(
(any AppContextProtocol).self,
parameterTypes: .explicit(Date.self), .explicit(String.self), .resolved((any Theme).self),
using: AppContext.init(boot:sessionId:theme:)
)
extension Dependency { }
This will expose a resolve
method that exposes Date
and String
while implicitly resolving Theme
.
extension Dependency {
static func resolve(_: any AppContextProtocol, boot: Date, sessionId: String) -> any AppContextProtocol {
return (AppContextProtocolImpl.init(boot:sessionId:theme:))(boot, sessionId, Self.resolve())
}
}
All registered types (save for singletons) now expose factory methods/types.
If a dependency that requires no parameters for resolution, a single factory(of: ...)
method will be exposed.
E.g. for the type:
@AutoRegister((any ABTestingProtocol).self, using: ABTesting.init)
extension Dependency { ... }
A single factory method will be exposed:
extension Dependency {
...
static func factory(of _: (any ABTestingProtocol).Type) -> MDIFactory<any ABTestingProtocol> {
return Dependency.resolve((any ABTestingProtocol).self)
}
}
For opaque types, the main difference is that MDIFactory
returns some ABTestingProtocol
instead of the existencial.
Whenever a dependency requires parameters for resolution, a factory type will be created and two factory methods will be exposed.
For the following example:
@FactoryRegister(
(any AppContextProtocol).self,
parameterTypes: .explicit(Date.self), .explicit(String.self), .resolved((any Theme).self),
using: AppContext.init(boot:sessionId:theme:)
)
extension Dependency { }
A type AppContextProtocolFactory
will be declared, and two methods exposed:
extension Dependency {
...
struct AppContextProtocolFactory {
fileprivate init() {
}
public func make(boot: Date, sessionId: String) -> any AppContextProtocol {
return Dependency.resolve((any AppContextProtocol).self, boot: boot, sessionId: sessionId)
}
}
static func factory(of: (any AppContextProtocol).Type) -> AppContextProtocolFactory {
return AppContextProtocolFactory()
}
static func factory(of: (any AppContextProtocol).Type, boot: Date, sessionId: String) -> MDIFactory<any AppContextProtocol> {
return MDIFactory {
MDIDependency.resolve((any AppContextProtocol).self, boot: boot, sessionId: sessionId)
}
}
}
Variants for the previous macros exist, supporting opaque types:
@OpaqueAutoRegister
is equivalent to@AutoRegister
@OpaqueSingletonRegister
is equivalent to@SingletonRegister
@OpaqueFactoryRegister
is equivalent to@FactoryRegister
The main differences being:
- All macros expect explicit typing of the factory's parameters through
parameterTypes
; these will be used to univoquely resolve types - The factory used to create the instance must resolve into a concrete type, or to an opaque type itself
You can use the Swift Package Manager to install your package by adding it as a dependency to your Package.swift
file:
dependencies: [
.package(url: "git@github.com:renato-iar/MDI.git", from: "1.0.0")
]