Dependency injection in Swift often starts simple: pass a client into an initializer and keep going. That works well until the same dependency has to reach models, helper functions, asynchronous work, previews, and tests.
At that point, the difficult part is not storing a value. It is deciding which value should be visible to which piece of work, and for how long.
swift-dependencies takes an interesting approach to that problem. Instead of building around a container object that you pass everywhere, it stores the current typed dependency collection in Swift task-local state.
That implementation choice explains most of the library.
A dependency is still just a value
A custom dependency starts with a key that provides its live implementation:
import Dependencies
struct InvoiceNumberGenerator: Sendable {
var next: @Sendable () -> String
}
extension InvoiceNumberGenerator: DependencyKey {
static let liveValue = Self {
UUID().uuidString
}
}
extension DependencyValues {
var invoiceNumber: InvoiceNumberGenerator {
get { self[InvoiceNumberGenerator.self] }
set { self[InvoiceNumberGenerator.self] = newValue }
}
}
A feature can then ask for that value:
struct InvoiceService {
@Dependency(\.invoiceNumber) var invoiceNumber
func makeNumber() -> String {
invoiceNumber.next()
}
}
There is no protocol requirement here. The dependency can be a small struct of closures, a reference type, or another Sendable value that fits the application.
The important part is the lookup. @Dependency does not own the dependency. It reads it from DependencyValues.
Overrides are scoped to work
Testing is where the model becomes more useful.
import Dependencies
import Testing
@Test
func invoiceNumberIsDeterministic() {
let value = withDependencies {
$0.invoiceNumber = InvoiceNumberGenerator {
"INV-0001"
}
} operation: {
InvoiceService().makeNumber()
}
#expect(value == "INV-0001")
}
The override exists for the operation. Code executed there sees the modified collection. Code outside it sees the previous dependency values.
The same mechanism works with built-in dependencies. For example, the package provides a date generator:
import Dependencies
import Foundation
import Testing
@Test
func generatedDateCanBePinned() {
let expected = Date(timeIntervalSince1970: 1_700_000_000)
let actual = withDependencies {
$0.date.now = expected
} operation: {
@Dependency(\.date.now) var now
return now
}
#expect(actual == expected)
}
This is a small example, but it shows an important design choice. Time is treated as an input that can be replaced rather than an ambient fact that tests must work around.
The core is @TaskLocal
The most useful file to read is DependencyValues.swift.
The current dependency collection is declared as task-local state:
@TaskLocal public static var _current = Self()
Then withDependencies takes the current collection, applies changes to a copy, and runs the operation with that copy installed as the current task-local value.
Conceptually, the flow is:
current values
↓
copy
↓
apply overrides
↓
install for operation
↓
restore previous context
This gives nested operations natural behavior. An inner scope can replace one dependency without permanently changing its parent scope.
It also matters for concurrency. Swift task-local values are inherited by child tasks, so a Task created inside an override can inherit that dependency context. But the library’s own documentation warns against assuming that arbitrary escaping closures keep the same values. Dependency lifetime follows the execution context, not simply lexical capture.
That distinction is easy to miss if you look only at the property-wrapper syntax.
@Dependency captures context carefully
Dependency.swift adds another piece.
The property wrapper records the dependency values that exist when it is initialized. When the wrapped value is read, it merges those initial values with the currently active values before resolving the key path.
This is why dependencies can follow models created inside a dependency scope while still allowing a more local override to take precedence later.
The repository tests exercise exactly this behavior with models, child models, nested withDependencies calls, and async operations.
This is more than convenience syntax around a dictionary. The library is trying to model dependency lifetime as part of program execution.
Live and test behavior are deliberately different
DependencyKey provides the live value used by the running application. The library also has test-specific dependency machinery.
In debug/test contexts, a dependency without an appropriate test implementation can report a test failure rather than quietly reaching into a live service. That is a useful pressure toward deterministic tests: if a test unexpectedly touches the real clock, network client, UUID generator, or another live dependency, the mistake can become visible.
Values are also cached after first access. That matters when a live value is created by a computed property. The library does not rebuild it on every lookup.
SwiftUI uses the same dependency model
The current implementation also connects @Dependency to SwiftUI.
When SwiftUI is available, Dependency conforms to DynamicProperty and reads dependency values from the SwiftUI environment. Public .dependency(...) modifiers on View and Scene let a subtree receive an override.
That means the library does not need a separate dependency system for views. The same DependencyValues model can be supplied through SwiftUI and used by @Dependency.
This is especially useful for previews or for replacing a dependency in one part of a view hierarchy.
The package itself is moving with modern Swift
Current Package.swift uses Swift tools 6.4 and Swift 6 language mode. It declares iOS 15, macOS 12, tvOS 15, and watchOS 9 as its Apple minimums.
The package is not tiny internally. Alongside the main Dependencies product, it contains macro and test-support products and depends on packages for clocks, schedulers, concurrency utilities, issue reporting, and SwiftSyntax.
That is worth considering if your dependency needs are only a few initializer parameters. For a small object graph, plain initializer injection remains extremely clear and requires no library.
The value of swift-dependencies becomes easier to see when dependencies must cross asynchronous work, previews, tests, and many feature types while retaining controlled override lifetimes.
What I would read in the source
Start with DependencyValues.swift and WithDependencies.swift. Together they show the storage model and the scoping mechanism.
Then read Dependency.swift to understand why a property wrapper can preserve the context in which a model was created while still respecting newer overrides.
Finally, DependencyKey.swift is worth reading for the live/test split, and DependencyTests.swift has useful examples of nested and asynchronous lifetime behavior.
The interesting idea in this project is not dependency injection itself. Swift has many ways to do that.
It is the decision to make dependency context follow Swift’s execution model. Once the current values live in task-local state, scoped overrides, async propagation, testing, and SwiftUI integration become parts of the same design rather than separate features.
