Skip to content

elf0-fr/ETBDependencyInjection

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

67 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ETBDependencyInjection

Compile-time Dependency Injection with Swift Macros

Overview

ETBDependencyInjection is a Swift Package that provides a Swift macro-driven approach to dependency injection. It eliminates boilerplate for wiring services into your types while keeping zero runtime cost. Use the @Injection property wrapper and accompanying macros to declare dependencies declaratively; the macro generates the necessary accessors at compile time.

  • Zero runtime overhead: All code is generated by the macro at compile time.
  • Declarative and concise: Express dependencies with @Injection on properties.
  • Flexible resolution: Support for protocols and any existential types.
  • Non-invasive: If a property is already initialized or overridden, the macro does nothing.
  • Testable by design: Swap providers or register test doubles in your tests.

Requirements

  • Swift 6.2 or later (the package uses swift-tools-version: 6.2)
  • Platforms:
    • macOS 14+
    • iOS 17+
    • tvOS 17+
    • watchOS 10+
    • macCatalyst 17+

Installation

Xcode

  1. In Xcode, go to File > Add Package Dependencies…
  2. Enter the package URL: https://github.com/elf0-fr/ETBDependencyInjection.git
  3. Add the ETBDependencyInjection product to your target.

Swift Package Manager (Package.swift)

Add the package to your dependencies and link the ETBDependencyInjection product to your target:

// In Package.swift
dependencies: [
    .package(url: "https://github.com/elf0-fr/ETBDependencyInjection.git", from: "1.0.0")
],
targets: [
    .target(
        name: "YourTarget",
        dependencies: [
            .product(name: "ETBDependencyInjection", package: "ETBDependencyInjection")
        ]
    )
]

Example

Add a concrete implementation for the protocols

public final class ContainerImp: Container {
    // Your implementation or delegation to a DI framework
}
public final class ServiceCollectionImp: ServiceCollection {
    // Your implementation or delegation to a DI framework
}
public final class ServiceProviderImp: ServiceProvider {
    // Your implementation or delegation to a DI framework
}

Declare a service contract

protocol MyService {
    func doWork() -> String
}

Apply the Service macro to a service you want to inject in your dependency injection system.

@Service(MyService.self)
class MyServiceImp: MyService {
    // Resolve dependency within a service
    @Injection var anotherService: any AnotherService

    // Add your MyService protocol conformance:
    func doWork() -> String { anotherService.doAnotherWork() }
}

// Here is the expanded version
class MyServiceImp: MyService {
    // To simplify I did not expand the Injection macro here.
    @Injection var anotherService: any AnotherService

    typealias Interface = any MyService

    var provider: (any ServiceProvider)?

    required init(provider: any ServiceProvider) {
        self.provider = provider
    }

    init(anotherService: any AnotherService) {
        self.anotherService = anotherService
    }
    
    func doWork() -> String { anotherService.doAnotherWork() }
}

Create the container, register the services, build and share the provider with the views (example API)

@main
struct YourApp: App {
    @State private var provider: ServiceProvider

    init() {
        // Create the container
        let container = ContainerImp()

        // Register your services
        _ = try? container.services.register(as: MyServiceImp.self)

        // Build to create the provider
        container.build()

        do {
          _provider = .init(wrappedValue: try container.provider)
        } catch {
          // handle error
        }
    }

    var body: some Scene {
        YourView(provider: provider)
    }
}

Consume the dependency. Apply the Injectable macro to an entity that will consume your dependencies but will not be part of it.

@Injectable
class ViewModel {
    // Resolve dependency
    @Injection var service: any MyService

    var provider: (any ServiceProvider)?

    // Unlike Service, Injectable does not require a init(provider:) initializer.
    // You are free to initialize self.provider as you like.
    init(provider: any ServiceProvider) {
        self.provider = provider
    }

    // Manualy inject dependency is also possible
    init(service: any MyService) {
        self.service = service
    }

    func run() {
        print(service.doWork())
    }
}

// Special case for Observation
@Injectable
@Observable
class ViewModel {
    // add @ObservationIgnored after @Injection
    @Injection @ObservationIgnored var service: any MyService

    // ...
}

The Injection macro

class MyClass {
    @Injection var service: any MyService

     var provider: (any ServiceProvider)?
}

// Here is the expanded version
class MyClass {
    var service: any MyService {
        get {
            if _injection_service == nil {
                _injection_service = provider?.resolveRequired((any MyService).self)
            }

            if let _injection_service {
                return _injection_service
            } else {
                fatalError()
            }
        }
        set {
            _injection_service = newValue
        }
    }

    private var _injection_service: (any MyService)?

     var provider: (any ServiceProvider)?
}

Resolving a concrete implementation

In some cases you need access to the concrete implementation rather than the interface — for example, to configure a mock in a SwiftUI preview. Use resolveRequired(_:as:) (or its optional counterpart resolve(_:as:)) to resolve a service registered under its interface and cast it to the concrete type in one step.

// Resolve the concrete implementation directly
let mock = provider.resolveRequired(as: MyServiceMock.self)

// You can also specify the interface explicitly
let mock = provider.resolveRequired((any MyService).self, as: MyServiceMock.self)

This replaces the manual resolve-then-cast pattern:

// Before: verbose manual cast
let service = provider.resolveRequired((any MyService).self)
if let mock = service as? MyServiceMock {
    mock.configure(...)
}

// After: one-step resolution with concrete type
let mock = provider.resolveRequired(as: MyServiceMock.self)
mock.configure(...)

Note: The interface type defaults to Implementation.Interface.self, so in most cases you only need to pass the as: parameter. The method triggers a fatalError if the service is not registered or cannot be cast.

SwiftUI Preview Bootstrapping

The package provides a .bootstrap(action:) view modifier for SwiftUI previews. It displays a ProgressView while a setup closure runs, then shows the actual content. This is useful for registering dependencies or configuring mocks before a preview renders.

#Preview(traits: .serviceRegistrationPreviewModifier()) {

    @Previewable @Environment(\.provider) var provider
    @Previewable @State var viewModel: MyScreenViewModel?

    MyScreen()
        .environment(viewModel)
        .bootstrap {
            // Resolve the mock implementation directly thanks to resolveRequired(_:as:)
            let repository = provider.resolveRequired(as: MyRepositoryMock.self)

            // Configure mock data
            repository.mockItems = [
                .init(id: "1", title: "Hello, world!"),
                .init(id: "2", title: "Hi there!"),
            ]

            // Create the view model with the configured provider
            viewModel = .init(provider: provider)
        }
}

In this example:

  1. The serviceRegistrationPreviewModifier trait creates a ServiceProvider with mock implementations registered against their interfaces.
  2. The .bootstrap { ... } modifier runs the setup closure once before the view appears.
  3. resolveRequired(as: MyRepositoryMock.self) resolves the service registered under its interface and returns it as the concrete mock type, so you can configure it directly without manual casting.
  4. The view model is initialized with the provider and injected via .environment(...).

About

Dependency injection tool

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages