Swift-dependencies is an unacceptable solution for managing Swift dependencies

zethraeus1 pts0 comments

swift-dependencies is an unacceptable solution for handling dependencies in Swift apps<br>&larr; Blog May 18, 2026<br>swift-dependencies is an unacceptable solution for handling dependencies in Swift apps

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:

@TaskLocal storage. Every dependency lives in a task-local value. That means propagation obeys SE-0311’s rules — child Task {} inherits, Task.detached does not, and you cannot bind a task-local inside the body of withTaskGroup.3

Capture-at-init. The @Dependency property wrapper snapshots DependencyValues._current the 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 your liveValue is implemented as a computed property instead of a static 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 use yield(_:) 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 {<br>@Dependency(\.mainQueue) var mainQueue

func reduce(into state: inout State, action: Action) -> EffectAction> {<br>// Combine path. `.receive(on:)` calls scheduler.schedule, which is @escaping.<br>return .publisher {<br>Timer.publish(every: 1, on: .main, in: .default)<br>.autoconnect()<br>.receive(on: mainQueue) // ← override is lost in here<br>.map { _ in .tick }

let store = TestStore(initialState: .init()) { Feature() } withDependencies: {<br>$0.mainQueue = .immediate // never observed by .receive(on:)<br>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<br>@Dependency(\.processItem) var processItem

func processBatch(_...

dependencies swift task dependency escaping anything

Related Articles