Compile-time Dependency Injection with Swift Macros
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
@Injectionon properties. - Flexible resolution: Support for protocols and
anyexistential 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.
- Swift 6.2 or later (the package uses swift-tools-version: 6.2)
- Platforms:
- macOS 14+
- iOS 17+
- tvOS 17+
- watchOS 10+
- macCatalyst 17+
- In Xcode, go to File > Add Package Dependencies…
- Enter the package URL:
https://github.com/elf0-fr/ETBDependencyInjection.git - Add the
ETBDependencyInjectionproduct to your target.
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")
]
)
]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)?
}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 theas:parameter. The method triggers afatalErrorif the service is not registered or cannot be cast.
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:
- The
serviceRegistrationPreviewModifiertrait creates aServiceProviderwith mock implementations registered against their interfaces. - The
.bootstrap { ... }modifier runs the setup closure once before the view appears. 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.- The view model is initialized with the provider and injected via
.environment(...).