A hard-earned lesson.
I’ve worked on the platform and product levels of top-tier iOS apps for over 10 years. Code I wrote at Uber is invoked every time you order a car. An app I designed is used many thousands of times daily in top-tier hospitals across the US.1
I recently had the misfortune of having to retrofit a lifecycle into an app built with swift-dependencies — and this post is intended as both my retrospective and a PSA.
TL;DR
If you are building a real iOS, macOS, or server-side Swift app — anything with sign-in/sign-out, anything mixing Combine, NIO, GCD, delegates, or third-party SDKs with async/await, anything using task groups in earnest, anything where a dependency should live and die with a screen or session: do not adopt Point-Free’s swift-dependencies as your DI system. No matter how deeply you’re already invested in the TCA ecosystem.
The marketing promises “SwiftUI’s environment for everything”; the runtime ships a global @TaskLocal lookup with a hidden cache and no scope ownership, and the maintainers have confirmed on the record that the gaps you are about to hit are not bugs.
Use it for small systems like an app with a handful of globally scoped services. That is the configuration it was designed for, and it wins there.2 Elsewhere, the cost-to-correctness ratio is poor.
What follows are the receipts.
What’s actually in the box
Three primitives, in roughly the order they bite you:
@TaskLocalstorage. Every dependency lives in a task-local value. That means propagation obeys SE-0311’s rules — childTask {}inherits,Task.detacheddoes not, and you cannot bind a task-local inside the body ofwithTaskGroup.3- Capture-at-init. The
@Dependencyproperty wrapper snapshotsDependencyValues._currentthe moment the enclosing instance is initialized, then merges that snapshot with the current task-local on every read.4 - Cache-on-first-access. A
DependencyKey’s value is cached on first access and never recomputed. The doc comment says so out loud: “if yourliveValueis implemented as a computed property instead of astatic let, then it will only be called a single time.”5
The library does not propagate dependencies through your object graph or your view tree. It reads a global at constructor time, caches the result. Once you internalize that, every “weird” production bug below stops being weird.
Footgun #1: Escaping closures silently lose your overrides
This is the failure mode that ships to production. The library acknowledges it in WithDependencies.swift:
“Dependencies do not automatically propagate across escaping boundaries like they do in structured contexts and in
Tasks. … As a general rule, you should surround all escaping code that may access dependencies with this helper, and you should useyield(_:)immediately inside the escaping closure. Otherwise you run the risk of the escaped code using the wrong dependencies.”6
A canonical reproduction, abbreviated from TCA Discussion #1870:
struct Feature: Reducer {
@Dependency(\.mainQueue) var mainQueue
func reduce(into state: inout State, action: Action) -> Effect<Action> {
// Combine path. `.receive(on:)` calls scheduler.schedule, which is @escaping.
return .publisher {
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.receive(on: mainQueue) // ← override is lost in here
.map { _ in .tick }
}
}
}
let store = TestStore(initialState: .init()) { Feature() } withDependencies: {
$0.mainQueue = .immediate // never observed by .receive(on:)
}
Brandon Williams, on that thread: “this is not a bug with the library, but rather an intended consequence of using escaping closures. … This is happening because dependencies are built on top of @TaskLocals, which has some well-defined ways of propagating dependencies across escaping boundaries, but it can not do it generally.”7
This is not Combine-specific. The same reasoning reappears in Discussion #283 (Vapor’s transaction and NIO’s makeFutureWithTask)8 and in Discussion #335, where a user reports @Dependency(\.date.now) returning dates from the future or past in production — silently, intermittently, because the date was captured inside an escaping scheduler closure running the default live dependency instead of the override.9 “Use withEscapedDependencies everywhere” is the official answer. That is a correctness obligation the type system cannot enforce, applied to every closure that might escape, in every package you depend on, forever. It is a tax on vigilance, not a fix.
Footgun #2: Task groups crash at runtime
SE-0311 is explicit: “binding a task-local value is illegal within the body of a withTaskGroup invocation.”3 Most engineers don’t know that rule. They write the obvious thing:
@Dependency(\.logger) var logger
@Dependency(\.processItem) var processItem
func processBatch(_ items: [Item]) async {
await withTaskGroup(of: Void.self) { group in
for item in items {
// The obvious shape: per-item context override, then fan out.
withDependencies {
$0.logger = logger.tagged(item.id)
} operation: {
group.addTask { // ← runtime crash
await processItem(item)
}
}
}
}
}
That crashes with “illegal task-local value binding.” Per Discussion #195: “this is simply a limitation of TaskLocals when used in withTaskGroup. You can’t access task locals directly in the closure of withTaskGroup, but you can access them in the closure of group.addTask.”10 Every per-item override has to be hoisted inside its own addTask. This is not exotic concurrency — it is parallel fan-out, the most common reason to reach for a task group. The library makes the natural code crash and demands a specific shape the compiler does not help you find.
Footgun #3: The cache silently freezes your dependency graph
The cache is presented as an optimization. In practice it is a footgun for any dependency that resolves another dependency at construction time:
struct APIClient { var fetch: @Sendable () async throws -> Data }
extension APIClient: DependencyKey {
static var liveValue: APIClient {
// Resolved at first access, then cached forever.
@Dependency(\.authToken) var auth
return APIClient(
fetch: {
var req = URLRequest(url: .api)
req.setValue("Bearer \(auth.current)", forHTTPHeaderField: "Authorization")
return try await URLSession.shared.data(for: req).0
}
)
}
}
// Later, after sign-in:
withDependencies {
$0.authToken = .signedIn("real-token")
} operation: {
@Dependency(\.apiClient) var apiClient
// apiClient is the cached instance. Its `auth` was resolved *before*
// we set the override. The new token is silently ignored.
_ = try await apiClient.fetch()
}
Users reported exactly this in Discussion #238; the response was “working as intended, because uncached behavior was even more confusing.”11 Both halves are true, and that is the problem: the library has chosen a default that quietly corrupts the dependency graph the first time a liveValue reads from another @Dependency, and the only safe rule is “never do that,” which the compiler cannot enforce.
Footgun #4: There is no subtree lifetime. At all.
This is the deepest issue and the one most likely to bite a real product. The library has no concept of “this dependency belongs to this screen / session / tenant and should die when it does.” The maintainers state this directly. From Discussion #290, after a user demonstrated that an object returned from withDependencies kept an injected child alive past the parent’s deinit:
“Currently, dependencies are generally considered ‘static’ interfaces, and so you shouldn’t depend on any lifecycle guarantees for dependencies themselves. Dependencies attached to objects (like
ExampleParent) are eventually freed, but this is done lazily via a garbage collection process, and so those dependencies will not be purged till anotherwithDependenciescall is made.” — Stephen Celis12
From Discussion #42, on the obvious “I want a currentUser that’s non-nil after sign-in” use case:
“I don’t think there is any way to have a
currentUserdependency that is non-niland only accessible when the user is ‘logged in’. That kind of static typing information is just not available to the compiler, and it must be done at runtime. … In the situation of a user switch or logout I imagine you would recreate the base view of the application.” — Stephen Celis13
And the maintainer-recommended pattern for session-scoped APIs, from TCA Discussion #2825:
“Your dependency can hold onto mutable data and you can mutate it though a dependency endpoint. … The
authenticateendpoint mutates some internal data to represent the current user, andlogoutclears out that data. … I think that is typically a better approach thanReducerReader.” — Brandon Williams14
Take that pattern seriously and write it out:
// The "blessed" way to handle a session: hide mutable state inside the dep.
final class APIClientLive: Sendable {
private let token = LockIsolated<String?>(nil)
func authenticate(_ t: String) { token.setValue(t) }
func logout() { token.setValue(nil) }
func fetch() async throws -> Data { /* read token, build request… */ }
}
That is “DI” in the same sense a global mutable singleton is DI. You bury session state inside the dependency, keep lifetime entirely internal, and rebuild the world from the root on sign-out. The community’s would-be alternative — ReducerReader — was proposed in TCA PR #1969 in 2022 and closed without merging in January 2026, after three-plus years open.15 TCA has no first-class answer for child-from-parent dependency wiring, and has not had one for the library’s entire lifetime.
Footgun #5: Swift 6 strict concurrency makes it worse, not better
On Sep 5, 2025, a TCA maintainer-adjacent user posted a PSA titled “If you’re using Approachable Concurrency and MainActor isolation…”:
“If you have everything in packages, not enabling [
NonisolatedNonsendingByDefault] seems to work smoothly. … With Approachable Concurrency enabled, you need to makenonisolatedclient closures@concurrent @Sendable. … It’s been a very rough year.” — @ryanbooker16
The library’s surface area collides with Swift 6.2’s new isolation defaults in ways that turn ordinary nonisolated client closures into compile or runtime hazards unless each one is decorated. Separately, in April 2025, Celis confirmed SwiftData’s ModelContext “is not [Sendable], and so you cannot vend one directly from @Dependency.”17 And @DependencyClient, the macro most TCA shops use, has a documented bug generating invalid @Sendable functions under strict concurrency.18 Adopting this library today means inheriting a long tail of @Sendable/@MainActor/@concurrent decoration debt the maintainers are still triaging.
Where it’s actually fine
The narrow zone where swift-dependencies is genuinely the right tool:
- A single-entry-point server with mostly static, app-global service interfaces.
- Unit and snapshot test overrides for clocks, UUIDs, dates, and the like.
- SwiftUI previews that need a fake API client and nothing more.
- TCA reducers that already live entirely in structured
async/awaitand will never need a dependency to be scoped to anything narrower than “the app.”
Outside that zone, every advertised benefit is undercut by one of the footguns above.
Recommendation
For new code: pass dependencies as initializer arguments. Verbose, yes — also compile-time checked, with explicit lifetime, propagating across every closure flavor, surviving detached tasks and task groups, and requiring no memorized list of escaping-closure exceptions. If you need lifecycle-aware scopes (per-session, per-screen, per-tenant), reach for a library that actually models scopes: Uber’s Needle has explicit parent/child Component scopes with compile-time wiring.19
For existing TCA codebases on swift-dependencies: stop expanding its surface area. Treat any new @Dependency as a liability. Anything that needs to participate in a lifecycle, in concurrency primitives beyond plain async/await, or in a session boundary should be injected explicitly; existing call sites should be audited for missing withEscapedDependencies wrappers. The library will not tell you when you forgot.
The maintainers are talented and candid, and the library does what they designed it to do. The problem is that what they designed it to do is much narrower than how it gets used — and the gap between the API’s promise and the runtime’s reality is the exact shape of a production incident.
References
Footnotes
-
Time and businesses move on, and nothing substantive is made without others. These statements are true to the best of my knowledge as of writing — and I’m incredibly fortunate to have gotten to do these things with amazing coworkers whose careers have spanned the Silicon Valley gamut of companies from Apple, OpenAI, Reddit, Uber, etc.
(I’m not going to plug you all in a post as rant-adjacent as this one. But to every one of you: thank you and <3) ↩ -
Point-Free, “A new library to control dependencies.” https://www.pointfree.co/blog/posts/92-a-new-library-to-control-dependencies-and-avoid-letting-them-control-you ↩
-
SE-0311, “Task Local Values.” https://github.com/apple/swift-evolution/blob/main/proposals/0311-task-locals.md (“the detached task completely discards any contextual information from the creating task”; “binding a task-local value is illegal within the body of a withTaskGroup invocation.”) ↩ ↩2
-
Sources/Dependencies/Dependency.swift,main. https://github.com/pointfreeco/swift-dependencies/blob/main/Sources/Dependencies/Dependency.swift (let initialValues: DependencyValues = DependencyValues._current; “Static properties are lazily initialized in Swift, and so a static@Dependencywill lazily capture its dependency values wherever it is first accessed, and will likely produce unexpected behavior.”) ↩ -
Sources/Dependencies/DependencyKey.swift,main. https://github.com/pointfreeco/swift-dependencies/blob/main/Sources/Dependencies/DependencyKey.swift ↩ -
Sources/Dependencies/WithDependencies.swift,main. https://github.com/pointfreeco/swift-dependencies/blob/main/Sources/Dependencies/WithDependencies.swift ↩ -
TCA Discussion #1870, “Combine Effect doesn’t use overridden dependencies.” https://github.com/pointfreeco/swift-composable-architecture/discussions/1870 ↩
-
swift-dependencies Discussion #283, on Vapor
transactionand NIOmakeFutureWithTask(Oct 2024). https://github.com/pointfreeco/swift-dependencies/discussions/283#discussioncomment-10846172 (“escaping closures are the enemy of structured programming, and so therefore also the enemy of task locals or any of the tools in structured concurrency.”) ↩ -
swift-dependencies Discussion #335, “
@Dependency(\.date.now)is returning incorrect dates … when used in escaping closures” (Feb 2025). https://github.com/pointfreeco/swift-dependencies/discussions/335 ↩ -
swift-dependencies Discussion #195, mbrandonw answer (Mar 2024). https://github.com/pointfreeco/swift-dependencies/discussions/195#discussioncomment-8789447 ↩
-
swift-dependencies Discussion #238, “Parent dependency is resolved from cache after child has changed, instead of being recalculated.” https://github.com/pointfreeco/swift-dependencies/discussions/238 ↩
-
swift-dependencies Discussion #290, stephencelis answer (Oct 2024). https://github.com/pointfreeco/swift-dependencies/discussions/290#discussioncomment-10907464 ↩
-
swift-dependencies Discussion #42, stephencelis answer on session/
currentUserdependencies. https://github.com/pointfreeco/swift-dependencies/discussions/42 ↩ -
TCA Discussion #2825, mbrandonw answer (Feb 2024). https://github.com/pointfreeco/swift-composable-architecture/discussions/2825#discussioncomment-8511091 ↩
-
TCA PR #1969, “Add
ReducerReaderfor building reducers out of state and action.” Opened Oct 2022, closed without merging Jan 2026. https://github.com/pointfreeco/swift-composable-architecture/pull/1969 ↩ -
TCA Discussion #3769, “PSA: If you’re using Approachable Concurrency and MainActor isolation…” (Sep 2025). https://github.com/pointfreeco/swift-composable-architecture/discussions/3769 ↩
-
swift-dependencies Discussion #361, on
ModelContextandSendable(Apr 2025). https://github.com/pointfreeco/swift-dependencies/discussions/361 ↩ -
swift-dependencies Discussion #396, “
@DependencyClientgenerates invalid@Sendablefunction when closure parameter has a named argument.” https://github.com/pointfreeco/swift-dependencies/discussions/396 ↩ -
Uber, Needle. https://github.com/uber/needle ↩