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
KeyPathextensions onDependencies, 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) fromProcessInfo— branch onDependencies.contextto register different implementations per environment. - Override any dependency in
.previewand.test. Overrides outside those contexts trap; an override factory returning the wrong type also traps. - Forwarding lets a package declare a
KeyPathwhose concrete value is provided by the consuming app.
Limitations
- One instance per
KeyPathfor the lifetime of the container; no per-resolution lifetimes. - Dependencies are immutable in
.live; replacement is only available via overrides in.previewand.test. - The container is a process-wide singleton. Tests share state, so call
Dependencies.reset()between tests (currentlyinternal— 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 beSendable. 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-Sendabletype resolved in two different isolation contexts — e.g. a@MainActorview 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.Sendableis therefore required on the type itself, where the thread-safety story is known. For types without built-inSendable, use@MainActor(single-actor access),actor(concurrent access), or@unchecked Sendablewith 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
@retroactiveconformance 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