swift-blade

0.1.4

A macro powered dependency injection framework for Swift
shackley/swift-blade

What's New

0.1.4

2024-02-09T03:15:33Z

Fix component init generator bug

swift-blade

Swift-blade is a macro powered dependency injection framework for Swift.

It is heavily inspired by Dagger.

References

API Documentation

Installation

Swift Package Manager

Declare swift-blade as a dependency in your Package.swift file:

.package(url: "https://github.com/shackley/swift-blade", from: "0.1.4")

Add Blade as a dependency to your target(s):

dependencies: [
    .product(name: "Blade", package: "swift-blade")
]

Example

We’ll demonstrate dependency injection with swift-blade by building a simple coffee maker. For complete sample code that you can compile and run, see swift-blade-example.

Defining Types

We'll start by defining a few types for our coffee maker.

Swift-blade utilizes initializers for dependency injection, so we'll start by writing our classes just like we would if we weren't using a dependency injection framework.

protocol Heater {}

protocol Pump {}

class ElectricHeater: Heater {
    init() {}
}

class Thermosiphon: Pump {
    private let heater: Heater

    init(heater: Heater) {
        self.heater = heater
    }
}

class CoffeeMaker {
    private let heater: Heater
    private let pump: Pump

    init(heater: Heater, pump: Pump) {
        self.heater = heater
        self.pump = pump
    }
}

Declaring Providers

Swift-blade takes care of initializing instances of your application's classes and providing their dependencies.

In order to do so, swift-blade must be told how to obtain instances of each type that it encounters. This is where the @Provider attribute comes in.

Let's go back in and add the @Provider attribute to the initializers of our classes so that swift-blade knows how to obtain instances of them.”

Note

An initializer-based @Provider must have its return type specified via the @Provider attribute's of parameter.

class ElectricHeater: Heater {
    @Provider(of: ElectricHeater.self)
    init() {}
}

class Thermosiphon: Pump {
    private let heater: Heater

    @Provider(of: Thermosiphon.self)
    init(heater: Heater) {
        self.heater = heater
    }
}

class CoffeeMaker {
    private let heater: Heater
    private let pump: Pump

    @Provider(of: CoffeeMaker.self)
    init(heater: Heater, pump: Pump) {
        self.heater = heater
        self.pump = pump
    }
}

Now swift-blade can obtain instances of type ElectricHeater, Thermosiphon, and CoffeeMaker. However, it doesn't know how to satisfy dependencies of type Heater and Pump yet.

Since Heater and Pump are protocol types that can't be directly initialized, we'll define static @Provider functions that declare how dependencies of these types should be satisfied.

Static @Provider functions can have dependencies of their own. Since swift-blade knows how to obtain instances of type ElectricHeater and Thermosiphon, the providers for Heater and Pump can be written as:

@Provider
static func provideHeater(heater: ElectricHeater) -> Heater {
    heater
}

@Provider
static func providePump(pump: Thermosiphon) -> Pump {
    pump
}

This pattern is commonly used to alias a concrete type to a protocol that it conforms to.

Creating a Module

All providers must be registered to a module. Modules are just empty enums that have the @Module attribute.

Initializer-based @Providers are included in a module by specifying the provided type via the @Module attribute's provides parameter.

Static @Providers are embedded directly within a module.

@Module(provides: [ElectricHeater.self, Thermosiphon.self, CoffeeMaker.self])
public enum CoffeeModule {
    @Provider
    static func provideHeater(heater: ElectricHeater) -> Heater {
        heater
    }

    @Provider
    static func providePump(pump: Thermosiphon) -> Pump {
        pump
    }
}

Building the Graph

The provider functions form a graph of types, linked by their dependencies.

graph LR;
    CoffeeMaker-->Heater;
    CoffeeMaker-->Pump;
    Pump-->Thermosiphon;
    Thermosiphon-->Heater;
    Heater-->ElectricHeater;

To construct and access the graph, we'll first need to define its roots. That set is defined via a protocol with functions that have no arguments and return the root types.

By applying the @Component attribute the protocol and declaring the modules that can used to provide dependencies, swift-blade then fully generates an implementation of the protocol.

@Component(modules = [CoffeeModule.self])
protocol CoffeeShop {
    func maker() -> CoffeeMaker
}

The implementation has the same name as the protocol prefixed with "Blade". Now, our CoffeeApp can simply use the generated implementation of CoffeeShop to get a fully dependency-injected CoffeeMaker!

@main
struct CoffeeApp {
    static func main() {
        let coffeeShop = BladeCoffeeShop()
        let coffeeMaker = coffeeShop.maker()
    }
}

Advanced Usage

See API Documentation for advanced usage.

Frequently Asked Questions

Q: How is the dependency graph validated?

Unlike Dagger, a Blade component's dependency graph is validated at runtime immediately upon component initialization. If a dependency does not have a registered provider, a fatalError will occur. This is largely due to the fact that the current macro implementation does not have context APIs that allow a macro to glean semantic information about types found in a delcaration at the time of expansion. If such an APIs were to be added, this validation could potentially occurr at compile time.

Q: Why do @Providers attached to initializers have to specify their provided type?

Currently, swift macros are not provided with any lexical scope information at the time of expansion, so it isn't possible for a @Provider macro to know which type the initializer belongs to otherwise.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

Last updated: Sat May 04 2024 16:04:29 GMT-0900 (Hawaii-Aleutian Daylight Time)