Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

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.
  • 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)
    }
}

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

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

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

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.

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