Injector is a lightweight dependency injection framework for Swift.
It is built around a small container, strongly typed dependencies, key-path based resolution, and a @BindService macro that can register services automatically through Rhea.
- Strongly typed dependencies declared as
extension Injectorproperties. - Key-path based
bind,resolve,remove, and@InjectedAPIs. @BindServicemacro for colocating protocol bindings with service implementations.- Lazy Rhea registration: macro-generated bindings are installed before the first resolve.
- Manual module registration for app-level or infrastructure bindings.
- Instance binding for objects such as
UserDefaults, configuration, clients, and environment values. - Test override scopes that restore the previous registration table automatically.
Add Injector to your Swift package dependencies:
.package(url: "https://github.com/reers/Injector.git", branch: "main")Then add the product to your target:
.target(
name: "YourFeature",
dependencies: [
"Injector"
]
)Injector currently targets iOS 13, macOS 13, or later and uses Swift macros.
Declare your service protocol:
public protocol PaymentFeatureAPI {
func pay(amount: Decimal) -> String
}Expose the dependency as a regular property on Injector. Dependency is a top-level public type from the Injector module:
import Injector
public extension Injector {
var paymentService: Dependency<PaymentFeatureAPI> {
.service(PaymentFeatureAPI.self)
}
}Bind an implementation with the macro:
import Injector
@BindService(\.paymentService)
public final class PaymentService: PaymentFeatureAPI {
public init() {}
public func pay(amount: Decimal) -> String {
"Paid \(amount)"
}
}Resolve it directly:
let paymentService = Injector.shared.resolve(\.paymentService)Or inject it into another type:
public final class CheckoutViewModel {
@Injected(\.paymentService) private var paymentService
public func submit() -> String {
paymentService.pay(amount: 99)
}
}Injector intentionally uses named dependencies instead of type-only lookups:
public extension Injector {
var primaryPaymentService: Dependency<PaymentFeatureAPI> {
.service(PaymentFeatureAPI.self)
}
var fallbackPaymentService: Dependency<PaymentFeatureAPI> {
.service(PaymentFeatureAPI.self)
}
}This keeps call sites readable and allows multiple bindings for the same protocol or concrete type.
Public APIs accept dependency key paths:
Injector.shared.bind(\.primaryPaymentService) { _ in PrimaryPaymentService() }
Injector.shared.resolve(\.primaryPaymentService)
Injector.shared.remove(\.primaryPaymentService)There is no public resolve(PaymentFeatureAPI.self) style API.
Injector has two scopes:
public enum Scope {
case new
case singleton
}Manual bind defaults to .new, which creates a new instance for every resolve:
Injector.shared.bind(\.paymentService) { _ in
PaymentService()
}Use .singleton when the same instance should be reused:
Injector.shared.bind(\.paymentService, scope: .singleton) { _ in
PaymentService()
}@BindService defaults to .singleton, because feature API services are commonly module-level facade objects:
@BindService(\.paymentService)
public final class PaymentService: PaymentFeatureAPI {}Use .new with the macro when you want a fresh service on every resolve:
@BindService(\.temporaryService, scope: .new)
public final class TemporaryService: TemporaryFeatureAPI {}Some dependencies are not service types. They may be created from environment, process arguments, configuration files, suite names, or app lifecycle state.
For those cases, declare a dependency and bind the already-created instance:
import Foundation
import Injector
public extension Injector {
var appDefaults: Dependency<UserDefaults> {
.service(UserDefaults.self)
}
}
#premain {
let defaults = UserDefaults(suiteName: "com.example.app") ?? .standard
Injector.shared.bindInstance(\.appDefaults, defaults)
}Consumers use it the same way as service dependencies:
public final class SettingsStore {
@Injected(\.appDefaults) private var defaults
}@BindService generates a Rhea registration callback inside the annotated type. Injector exposes a dedicated Rhea event:
public extension RheaEvent {
static let injectorBindService: RheaEvent = "injectorBindService"
}You may trigger all service bindings eagerly during startup:
#premain {
Injector.shared.installRheaServiceBindingsIfNeeded()
}You can also skip the startup hook. Injector.resolve lazily installs Rhea service bindings before the first resolve.
Rhea registrations generated by @BindService are not repeatable. Once services are bound, the same callback does not need to run again.
For app-level bindings, infrastructure setup, or cases where a macro is not appropriate, define a module:
import Injector
public enum AppModule: Module {
public static func register(into injector: Injector) {
injector.bind(\.paymentService, scope: .singleton) { _ in
PaymentService()
}
}
}
Injector.shared.install(AppModule.self)Use withOverrides to replace dependencies during a test without leaking registrations into other tests:
let receipt = try Injector.withOverrides {
$0.bind(\.paymentService, scope: .singleton) { _ in
MockPaymentService()
}
} operation: {
let service = Injector.current.resolve(\.paymentService)
// Assert against the mock-backed behavior.
return service.pay(amount: 99)
}@Injected reads from Injector.current, so objects created inside an override scope automatically use the scoped bindings:
let output = Injector.withOverrides {
$0.bind(\.paymentService, scope: .singleton) { _ in
MockPaymentService()
}
} operation: {
CheckoutViewModel().submit()
}The same API is available for async tests:
let output = try await Injector.withOverrides {
$0.bind(\.paymentService, scope: .singleton) { _ in
MockPaymentService()
}
} operation: {
try await CheckoutViewModel().submit()
}The recommended public surface is:
public extension Injector {
var paymentService: Dependency<PaymentFeatureAPI> {
.service(PaymentFeatureAPI.self)
}
}
@BindService(\.paymentService)
public final class PaymentService: PaymentFeatureAPI {}
@Injected(\.paymentService) private var paymentService
Injector.shared.bind(\.paymentService) { _ in PaymentService() }
Injector.shared.bindInstance(\.appDefaults, defaults)
Injector.shared.resolve(\.paymentService)
Injector.withOverrides {
$0.bind(\.paymentService) { _ in MockPaymentService() }
} operation: {
CheckoutViewModel().submit()
}Dependency and Scope are top-level public types. After import Injector, use them directly as Dependency<Service> and Scope.
This package does not currently include a license file.