A compile time dependency injection library for Swift
Sword is a compile time dependency injection library for Swift, inspired by Dagger.
As you declare dependencies and specify how to satisfy them using Swift Macros, Sword automatically generates dependency injection code at compile time. Sword walks through your code and validates dependency graphs, ensuring that every object's dependencies can be satisfied, so there are no runtime errors.
Use the following link to add Sword as a Package Dependency to an Xcode project:
https://github.com/rockname/sword
Important
Do not add the SwordCommand
executable to any targets.
Ensure None
is selected when asked to choose package products.
Add the following to the package dependencies
in your Package.swift
:
.package(url: "https://github.com/rockname/sword.git", from: "<version>")
Then, include "Sword" as a dependency for your target:
.target(
name: "<target>",
dependencies: [
.product(name: "Sword", package: "sword"),
]
),
Sword provides a build tool plugin to generate dependency injection code.
The build tool plugin can be used in both Xcode projects and Swift Package projects.
Note
Requires installing via Xcode Package Dependency.
Add the SwordBuildToolPlugin
to the Run Build Tool Plug-ins
phase of the Build Phases
for the target.
Tip
When using the plugin for the first time, be sure to trust and enable it when prompted. If a macros build warning exists, select it to trust and enable the macros as well.
Note
Requires installing via Swift Package Manager.
Add the plugin to the application root target as follows:
.target(
...
plugins: [.plugin(name: "SwordBuildToolPlugin", package: "Sword")]
),
Add the SwordBuildToolPlugin
as mentioned above Xcode projects.
Then add a .sword.yml
file into your Xcode project's root directory for Sword to read the file and generate your dependency graph considering local Swift Packages.
For example:
local_packages:
- path: PackageA
targets:
- DependencyA
- DependencyB
- path: PackageB
targets:
- DependencyC
- DependencyD
Consider an example SwiftUI app with the dependency graph from the following image.
You usually create a Sword dependency graph in your App struct (or root View) because you want an instance of the graph to be in memory as long as the app is running. In this way, the graph is attached to the app lifecycle.
In Sword, @Component
is attached to the dependency graph. So you can call it AppComponent
. You usually keep an instance of that component in your custom App
struct as shown in the following:
// Definition of the App dependency graph
@Component
final class AppComponent {
}
// AppComponent lives in the App struct to share its lifecycle
@main
struct MyApp: App {
let component = AppComponent()
var body: some Scene { ... }
}
Instead of creating the dependencies a View requires in the init
, you can get a dependency you want from the Component.
struct LoginNavigation: View {
let component: AppComponent
var body: some View {
...
LoginScreen(viewModel: component.loginViewModel)
...
}
}
struct LoginScreen: View {
let viewModel: LoginViewModel
var body: some View { ... }
}
Sword needs to know required dependencies to provide the LoginViewModel
. You can tell Sword how to initialize LoginViewModel
using @Dependency
/ @Injected
like following:
// You want Sword to provide an object of LoginViewModel from the AppComponent graph
@Dependency(registeredTo: AppComponent.self)
final class LoginViewModel {
private let userRepository: UserRepository
@Injected
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
}
Let's tell Sword how to provide the rest of the dependencies to build the graph:
@Dependency(registeredTo: AppComponent.self)
final class UserRepository {
private let apiClient: APIClient
@Injected
init(apiClient: APIClient) {
self.apiClient = apiClient
}
}
@Dependency(registeredTo: AppComponent.self)
final struct APIClient {
private let urlSession: URLSession
@Injected
init(urlSession: URLSession) {
self.urlSession = urlSession
}
}
When you provide an interface for a dependency, use boundTo
parameter on @Dependency
.
protocol APIClient { ... }
// You want Sword to provide a DefaultAPIClient implementation for APIClient interface
@Dependency(
registeredTo: AppComponent.self,
boundTo: APIClient.self
)
final struct DefaultAPIClient: APIClient {
...
}
For this example, APIClient
has a dependency on URLSession
. However, the way to create an instance of URLSession
is different from what you've been doing until now. It's initializer is defined in the Foundation framework.
Apart from the @Injected
, there's another way to tell Sword how to provide a required dependency: the information inside Sword modules. A Sword module is a struct that is attached with @Module
. There, you can define dependencies with the @Provider
.
// @Module informs Sword that this struct is a Sword Module registered to AppComponent
@Module(registeredTo: AppComponent.self)
struct AppModule {
// @Provider tells Sword how to create the dependency.
@Provider
static func urlSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
return URLSession(configuration: configuration)
}
}
This is how the Sword graph in the example looks right now:
The entry point to the graph is LoginScreen
. Because LoginScreen
injects LoginViewModel
, Sword builds a graph that knows how to provide an instance of LoginViewModel
, and recursively, of its dependencies. Sword knows how to do this because of the @Injected
on the dependencies' initializer.
You can use Scope
to limit the lifetime of an object to the lifetime of its component. This means that the same instance of a dependency is used every time that type needs to be provided.
To have a unique instance of a UserRepository
when you ask for the repository in AppComponent
, pass .single
to the scopedWith
parameter on @Dependency
.
@Dependency(
registeredTo: AppComponent.self,
scopedWith: .single
)
final class UserRepository { ... }
You can also use the scopedWith
parameter in @Provider
.
@Module(registeredTo: AppComponent.self)
struct AppModule {
@Provider(scopedWith: .single)
static func urlSession() -> URLSession { ... }
}
If your login flow consists of multiple views, you would want to reuse the same instance of LoginViewModel
in all views. But you should not use signle
scope in AppComponent
for the following reasons:
-
The instance of
LoginViewModel
would persist in memory after the login flow has finished. -
You want a different instance of
LoginViewModel
for each login flow. For example, if the user logs out, you want a different instance ofLoginViewModel
, rather than the same instance as when the user logged in for the first time.
To scope LoginViewModel
to the lifecycle of login flow, you need to create a new component for the login flow.
The new component must be able to access the objects from AppComponent
because LoginViewModel
depends on UserRepository
. The way to tell Sword that you want a new component to use part of another component is with Sword Subcomponent
. The new component must be a subcomponent of the component containing shared resources.
In the example, you must define LoginComponent
as a subcomponent of AppComponent
like following:
// You tell Sword that LoginComponent is a subcomponent of AppComponent
@Subcomponent(of: AppComponent.self)
final class LoginComponent {
}
A factory method func makeLoginComponent() -> LoginComponent
will be generated in AppComponent
.
You call this method when starting the login flow.
@main
struct MyApp: App {
let component = AppComponent()
var body: some Scene {
...
LoginNavigation(component: component.makeLoginComponent())
...
}
}
struct LoginNavigation: View {
let component: LoginComponent
var body: some View {
...
LoginScreen(viewModel: component.loginViewModel)
...
}
}
Then, as you set LoginComponent.self
to registeredTo
and .single
to scopedWith
on @Dependency
of LoginViewModel
, the instance of LoginViewModel
would be unique in each login flow.
@Dependency(
registeredTo: LoginComponent.self,
scopedWith: .single
)
final class LoginViewModel { ... }
Here is how the Sword graph looks with the new subcomponent. The classes with a white dot (UserRepository
, URLSession
, and LoginViewModel
) are the ones that have a unique instance scoped to their respective components.
You can pass some arguments to a component as a dependency.
For example, you can inject environment variables, EnvVars
, to AppComponent
like following:
struct EnvVars {
let baseURL: URL
}
@Component(arguments: EnvVars.self)
final class AppComponent {
}
Then, the @Component
macro generates an initializer receiving EnvVars
as a parameter.
let component = AppComponent(
envVars: EnvVars(baseURL: URL(string: "https://example.com")!)
)
Now you can resolve an EnvVars
dependency via AppComponent
.
@Dependency(
registeredTo: AppComponent.self,
boundTo: APIClient.self,
scopedWith: .single
)
final class DefaultAPIClient: APIClient {
private let baseURL: URL
@Injected
init(envVars: EnvVars) {
self.baseURL = envVars.baseURL
}
}
Assisted injection is a dependency injection (DI) pattern that is used to construct an object where some parameters may be provided by the DI framework and others must be passed in at creation time (a.k.a “assisted”) by the user.
To use Sword’s assisted injection, annotate any assisted parameters with @Assisted
, as shown below:
@Dependency(registeredTo: AppComponent.self)
class UserDetailViewModel {
...
@Injected
init(
@Assisted userID: User.ID,
userRepository: UserRepository
) {
self.userID = userID
self.userRepository = userRepository
}
}
Then you can pass the assisted parameter when using the dependency as shown below.
struct UserNavigation: View {
let component: AppComponent
var body: some View {
...
UserDetailScreen(viewModel: component.userDetailViewModel(userID: userID))
...
}
}
Feature | Support Status |
---|---|
Subcomponent | ✅ Supported |
Component Arguments | ✅ Supported |
Single Scope | ✅ Supported |
Weak Reference Scope | ✅ Supported |
Assisted Injection | ✅ Supported |
Missing Dependency Error | ✅ Supported |
Duplicate Dependency Error | ✅ Supported |
Cycle Dependency Error | 🚧 TBD |
This library is released under the MIT license. See LICENSE for details.