Contents

simonnickel/snap-dependencies

This package is part of the SNAP suite.

Requirements

  • iOS 18+ / macOS 15+
  • Swift 6.3+ (swift-tools-version 6.3)
  • Depends on snap-foundation

Features

  • Define dependencies as KeyPath extensions on Dependencies, allowing distributed setup across modules.
  • Resolve with @Dependency, with two resolution modes:

.lazy (default) — resolves on every read; observes overrides set after the owner is constructed. .captured — resolves once at owner-init and stores the value; cheaper reads; cannot observe later overrides.

  • All dependency types must be Sendable — see Design notes for why.
  • Auto-detected Context (.live, .preview, .test) from ProcessInfo — branch on Dependencies.context to register different implementations per environment.
  • Override any dependency in .preview and .test. Overrides outside those contexts trap; an override factory returning the wrong type also traps.
  • Forwarding lets a package declare a KeyPath whose concrete value is provided by the consuming app.

Limitations

  • One instance per KeyPath for the lifetime of the container; no per-resolution lifetimes.
  • Dependencies are immutable in .live; replacement is only available via overrides in .preview and .test.
  • The container is a process-wide singleton. Tests share state, so call Dependencies.reset() between tests (currently internal — consumers need @testable import).

Demo project

The demo project shows a full setup including overrides in #Preview, an @Observable view model, and a long-init service.

<img src="/screenshot.png" height="400">

Usage

Register

extension Dependencies {

    var service: Service { Service() }

    var contextual: Service {
        switch Dependencies.context {
            case .preview: ServicePreview()
            case .test:    ServiceTest()
            default:       ServiceLive()
        }
    }
}

Resolve

@MainActor
@Observable
class DataSource {

    @ObservationIgnored
    @Dependency(\.service) var service                          // resolves on every read

    @ObservationIgnored
    @Dependency(\.service, resolve: .captured) var captured    // resolves once at init
}

Use .lazy for SwiftUI views (SwiftUI may construct views before #Preview {} sets the override) and any owner whose dependencies might be overridden after construction. Use .captured for long-lived owners that read on hot paths and are constructed after overrides are set.

Override in #Preview

#Preview {
    Dependencies.overrideResettingAll(\.service) { ServicePreview() }
    return ContentView()
}

overrideResettingAll clears every cached instance in the container, so the next resolution of any dependency builds a fresh value with the override in effect. Existing resolve: .captured owners that already captured a value are unaffected — only .captured owners constructed after the reset see the new override. Existing .lazy owners always observe the current container state on their next read (SwiftUI typically reconstructs preview views, which is why this works in #Preview). Use the lighter Dependencies.override(_:with:) when only the overridden key needs invalidating.

Override in tests

Dependencies.reset() is internal, so the test target needs @testable import SnapDependencies.

@testable import SnapDependencies

@Suite
@MainActor
struct MyTests {
    init() { Dependencies.reset() }    // start each test from a clean cache

    @Test func someFeature() {
        Dependencies.override(\.service) { ServiceTest() }
        ...
    }
}

Forwarding (optional)

Declare a KeyPath in a package without committing to its implementation:

public extension Dependencies {
    var service: Service { Dependencies.forwarding(for: \.service) }
}

Provide the implementation in the consuming app:

extension Dependencies: @retroactive DependencyForwardingFactory {
    public func create<Dependency>(for keyPath: KeyPath<Dependencies, Dependency>) -> Dependency? {
        switch keyPath {
            case \.service: Service() as? Dependency
            default:        nil
        }
    }
}

Design notes

  • Thread safety: all mutable container state is guarded by OSAllocatedUnfairLock. Dependency types must be Sendable. The container is a process-wide singleton that caches and shares instances: the same object is returned to every caller that resolves a given key path. A non-Sendable type resolved in two different isolation contexts — e.g. a @MainActor view and an actor service — would produce concurrent access to the same object, a data race the compiler cannot detect because the two resolutions appear independent. Sendable is therefore required on the type itself, where the thread-safety story is known. For types without built-in Sendable, use @MainActor (single-actor access), actor (concurrent access), or @unchecked Sendable with internal synchronisation.
  • Build outside the lock: a factory that itself resolves another dependency does not deadlock on lock re-entry. A double-check on insert ensures two threads racing on the same key converge on a single cached instance.
  • Override-version race detection: an override registered while a build is in flight bumps a version counter; the in-flight build detects the mismatch at commit and re-resolves, so a stale value is never cached after a concurrent override.
  • Type-safe overrides: an override factory returning the wrong type traps with a clear message rather than silently falling back to the default.

ToDo

  • Make reset() public (or provide a public test-support target) to avoid requiring @testable import.
  • Scoped containers / child containers for per-feature or per-screen dependency lifetimes.
  • Async factory support — factories are currently synchronous.
  • Compile-time registration validation (e.g. via macro or build plugin) to catch unregistered dependencies before runtime.
  • Cycle detection — trap with a clear message instead of infinite recursion when factories form a cycle.
  • Simplify forwarding — replace the @retroactive conformance pattern with a registration-based API.
  • Explore TaskLocal instead of overrides.
  • Allow multiple Container to support parallel testing.

Package Metadata

Repository: simonnickel/snap-dependencies

Default branch: main

README: README.md