Skip to content

fonok3/swift-pin

Repository files navigation

CI

Pin

Opinionated compile-time dependency injection for Swift.

The Idea

Most DI frameworks give you a runtime container and ask you to register, resolve, and manage object lifetimes. Pin doesn't. Pin's opinion is simpler:

Your dependency graph is just @MainActor classes with lazy var properties.

lazy var is the lifecycle. There are no scopes, no containers, no service locators. A dependency is created once, on first access, and lives as long as its component. Swift already has the mechanism; Pin just generates the boilerplate that wires it up.

Pin never leaks into your implementations. Your view models, services, and other types use plain initializer parameters: no framework imports, no property wrappers, no protocol conformances. The component is the only layer that knows Pin exists. Everything below it is just normal Swift code you can instantiate and test directly.

// Your implementation. No Pin import. No framework types. Just a plain init.
public final class FeatureViewModel {
    private let logger: Logger
    public init(logger: Logger) { self.logger = logger }
}

// The component is the only thing that touches Pin.
@PinComponent
@MainActor public final class AppComponent {
    public lazy var logger: Logger = .init()

    @PinSubcomponent var feature: FeatureComponent
}

@PinComponent(Logger.self)
@MainActor public final class FeatureComponent {
    public lazy var viewModel = FeatureViewModel(logger: dependency.logger)
}

The compiler type-checks the entire graph. If a parent can't provide what a child needs, you get a build error, not a runtime crash.

How It Works

Pin has two compile-time parts:

  1. @PinComponent macro generates a Dependency protocol and init(dependency:) for each component. With no arguments, it's a root (no dependencies). With types listed, those become the dependency contract.
  2. PinPlugin build tool scans your source files per target and generates PinGenerated.swift with Providing protocols and forwarding extensions that wire parent to child.

Components are @MainActor classes because lazy var is not thread-safe: concurrent first access is undefined behavior. Actor isolation eliminates this entirely. The macros enforce it.

Swift's access modifiers control what enters the graph. public properties are visible cross-target. internal properties are visible within the same target. private properties stay out entirely. No framework-specific annotations, just standard Swift.

Usage

Components

A root component has no dependencies. A child lists what it needs:

import Pin

// Root: no arguments
@PinComponent
@MainActor public final class AppComponent {
    public lazy var logger: Logger = .init()
    public lazy var httpClient: HTTPClient = .init()

    @PinSubcomponent var feature: FeatureComponent
    @PinSubcomponent var settings: SettingsComponent
}

// Child: declares Logger as a dependency
@PinComponent(Logger.self)
@MainActor public final class FeatureComponent {
    public lazy var viewModel = FeatureViewModel(logger: dependency.logger)
}

@PinSubcomponent creates and owns the child component lazily. The property must be a plain var with a type annotation — no lazy, no initializer.

For named dependencies (multiple instances of the same type), use the verbose form:

@PinComponent(dependencies: [PinDependency(Logger.self, named: "networkLogger")])

Unowned Components

@PinSubcomponent ties a child's lifetime to its parent — created lazily, destroyed together. Sometimes you need a component whose lifetime you control yourself. Use from: to tell Pin where the dependencies come from without creating a parent-child relationship:

@PinComponent(Logger.self, from: AppComponent.self)
@MainActor public final class CarPlayComponent {
    public lazy var dashboard = CarPlayDashboard(logger: dependency.logger)
}

Pin generates extension AppComponent: CarPlayComponentDependency {} so you can create and destroy the component on your own terms:

// You control the lifetime
carPlayComponent = CarPlayComponent(dependency: appComponent)
carPlayComponent = nil // gone — no impact on AppComponent

Both approaches are compile-time safe. Here's how they compare:

Root Subcomponent Unowned
Declaration @PinComponent @PinComponent(T.self) @PinComponent(…, from: X.self)
Wiring none @PinSubcomponent var child: C C(dependency: x)
Lifetime you manage tied to parent you manage
Dependencies none implicit from parent explicit via from:

Testing

Pin follows the Dependency Inversion Principle strictly. Your implementations never import Pin; they use plain initializer parameters. Test them directly:

let viewModel = FeatureViewModel(logger: MockLogger())
viewModel.doSomething()
#expect(mockLogger.lastMessage == "did something")

No containers to configure. No mocks to register. Nothing to tear down.

Landscape

Swift DI frameworks generally fall into two camps: runtime containers (Swinject, Factory) that register and resolve at runtime, and code-generated hierarchies (Needle) that verify the graph at compile time. Pin is in the second camp, closest to Needle architecturally, but without the base class, without the runtime registry, and using Swift macros instead of a separate code generator.

Pin Swinject Needle Factory swift-dependencies SwiftUI Environment
Compile-time safe yes no yes partial partial no
No framework leakage yes optional no (Component<T>) no (@Injected) no (@Dependency) no (@Environment)
Works outside views yes yes yes yes yes no
No runtime container yes no no no no no

Pin's tradeoff: it requires @MainActor classes and an acyclic component tree. Properties flow freely from any ancestor to any descendant, but there is no runtime resolution or dynamic swapping. If your app needs those, a container-based framework is a better fit.

Performance

Runtime: No framework code from Pin ships in your binary. The generated protocols and forwarding properties are thin wiring — no allocations, no containers, no service locators.

Build time: Two additions per target: a macro expansion (in-process, generates declarations) and a build plugin (separate process, parses source files, writes one PinGenerated.swift). The plugin walks the AST without type-checking. Generation scales with the number of @PinComponent classes in the target.

Requirements

  • Swift 5.10+
  • Apple platforms: iOS 13+, macOS 10.15+, tvOS 13+, watchOS 6+, visionOS 1+
  • Linux is supported (SwiftSyntax and Foundation work on Linux via swift-corelibs-foundation)
  • Pin is compile-time only — no framework code ships in your binary.

Installation

Add Pin to your Package.swift:

dependencies: [
    .package(url: "https://github.com/fonok3/swift-pin.git", from: "0.1.0"),
]

Then add the library and plugin to each target:

.target(
    name: "MyFeature",
    dependencies: [
        .product(name: "Pin", package: "swift-pin"),
    ],
    plugins: [
        .plugin(name: "PinPlugin", package: "swift-pin"),
    ]
)

License

MIT. See LICENSE for details.

About

Opinionated compile-time dependency injection for Swift. No containers, no runtime leaking.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors