Skip to content
reersPublic

About

A lightweight Swift dependency injection framework

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

10 Commits

Folders and files

Repository files navigation

Injector

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.

Features

  • Strongly typed dependencies declared as extension Injector properties.
  • Key-path based bind, resolve, remove, and @Injected APIs.
  • @BindService macro 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: .session instances are dropped by endSession() on logout or account switch.
  • Test override scopes that restore the previous registration table automatically.

Installation

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.

Quick Start

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.

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.

Scopes

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 {}

User Session Scope

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 SessionScoped receive sessionDidEnd() once, outside the container lock, before endSession() returns. Use it to stop work in case someone still holds the old instance.
  • @Injected properties of .session dependencies 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 .session dependency while constructing a .singleton (directly or through .new dependencies) triggers an assertion, because the singleton would keep the first session's instance forever. Access session dependencies through @Injected, which follows endSession() even when read during construction, or call injected(...) 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 withOverrides have their own session: they never share session instances with the parent container, and endSession() on one does not affect the other.
  • reset() also drops session instances, but does not call sessionDidEnd().

Do not store a resolved .session service in a plain stored property; it would not follow endSession().

Binding Instances

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
}

Rhea Registration Timing

@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.

Manual Modules

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)

Test Overrides

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()
}

API Shape

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.

License

This package does not currently include a license file.

About

A lightweight Swift dependency injection framework

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages