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. - User session scope:
.sessioninstances are dropped byendSession()on logout or account switch. - 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)
}
}Or resolve inline without declaring a property:
let receipt = injected(\.paymentService).pay(amount: 99)@Injected captures Injector.current when its owner is created, resolves on first access, and reuses that instance afterwards, so a .new scoped dependency stays the same for one owner. injected resolves from Injector.current on every call, so it honors withOverrides scopes and returns a new instance per call for .new scoped dependencies.
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 three scopes:
public enum Scope {
case new
case singleton
case session
}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 {}Use .session for services that hold per-user state, so logout or account switching never leaks it to the next user. It behaves like .singleton within one session:
@BindService(\.membershipService, scope: .session)
final class DefaultMembershipService: MembershipService, SessionScoped {
func sessionDidEnd() {
// Invalidate timers, remove observers, cancel requests.
}
}Call endSession() from the single place where your app logs out:
Injector.shared.endSession()endSession() drops every .session instance but keeps the registrations, so the next resolve creates a new instance for the new user. In addition:
- Created instances that adopt
SessionScopedreceivesessionDidEnd()once, outside the container lock, beforeendSession()returns. Use it to stop work in case someone still holds the old instance. @Injectedproperties of.sessiondependencies resolve again on their next access, so long-lived owners such as root view controllers follow the new session automatically.injected(...)always resolves the current instance.- In debug builds, resolving a
.sessiondependency while constructing a.singleton(directly or through.newdependencies) triggers an assertion, because the singleton would keep the first session's instance forever. Access session dependencies through@Injected, which followsendSession()even when read during construction, or callinjected(...)where they are used. - An instance whose creation was racing with
endSession()is either notified as part of the ended session or never created; the caller then gets the new session's instance. - Containers created by
withOverrideshave their own session: they never share session instances with the parent container, andendSession()on one does not affect the other. reset()also drops session instances, but does not callsessionDidEnd().
Do not store a resolved .session service in a plain stored property; it would not follow endSession().
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 captures Injector.current when its owner is created, so objects created inside an override scope keep using the scoped bindings, even when they are accessed later from another thread or callback:
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
injected(\.paymentService).pay(amount: 99)
Injector.shared.bind(\.paymentService) { _ in PaymentService() }
Injector.shared.bindInstance(\.appDefaults, defaults)
Injector.shared.resolve(\.paymentService)
Injector.shared.endSession()
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.