← Blog

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:

  1. @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
  2. 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
  3. 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 {
  @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 another withDependencies call 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 currentUser dependency that is non-nil and 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 authenticate endpoint mutates some internal data to represent the current user, and logout clears out that data. … I think that is typically a better approach than ReducerReader.” — 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 make nonisolated client 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/await and 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

  1. 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)

  2. 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

  3. 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

  4. 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 @Dependency will lazily capture its dependency values wherever it is first accessed, and will likely produce unexpected behavior.”)

  5. Sources/Dependencies/DependencyKey.swift, main. https://github.com/pointfreeco/swift-dependencies/blob/main/Sources/Dependencies/DependencyKey.swift

  6. Sources/Dependencies/WithDependencies.swift, main. https://github.com/pointfreeco/swift-dependencies/blob/main/Sources/Dependencies/WithDependencies.swift

  7. TCA Discussion #1870, “Combine Effect doesn’t use overridden dependencies.” https://github.com/pointfreeco/swift-composable-architecture/discussions/1870

  8. swift-dependencies Discussion #283, on Vapor transaction and NIO makeFutureWithTask (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.”)

  9. 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

  10. swift-dependencies Discussion #195, mbrandonw answer (Mar 2024). https://github.com/pointfreeco/swift-dependencies/discussions/195#discussioncomment-8789447

  11. 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

  12. swift-dependencies Discussion #290, stephencelis answer (Oct 2024). https://github.com/pointfreeco/swift-dependencies/discussions/290#discussioncomment-10907464

  13. swift-dependencies Discussion #42, stephencelis answer on session/currentUser dependencies. https://github.com/pointfreeco/swift-dependencies/discussions/42

  14. TCA Discussion #2825, mbrandonw answer (Feb 2024). https://github.com/pointfreeco/swift-composable-architecture/discussions/2825#discussioncomment-8511091

  15. TCA PR #1969, “Add ReducerReader for building reducers out of state and action.” Opened Oct 2022, closed without merging Jan 2026. https://github.com/pointfreeco/swift-composable-architecture/pull/1969

  16. TCA Discussion #3769, “PSA: If you’re using Approachable Concurrency and MainActor isolation…” (Sep 2025). https://github.com/pointfreeco/swift-composable-architecture/discussions/3769

  17. swift-dependencies Discussion #361, on ModelContext and Sendable (Apr 2025). https://github.com/pointfreeco/swift-dependencies/discussions/361

  18. swift-dependencies Discussion #396, “@DependencyClient generates invalid @Sendable function when closure parameter has a named argument.” https://github.com/pointfreeco/swift-dependencies/discussions/396

  19. Uber, Needle. https://github.com/uber/needle