Replace `swift-service-context` with `swift-dependencies`

Need/problem

Some business logic in the Tuist CLI depends on core and stateful utilities that we need to mock to isolate tests and enable parallelization fully. For example, the environment variables. To dependency-inject those dependencies without compromising too much the code ergonomy by not having to declare the dependency in every function signature, we resorted to swift-service-context, which uses TaskLocal to propagate state down the structured task tree.

swift-service-context does the job, but the ergonomics is not as great as it can be. swift-dependencies by the PointFree team offers a TaskLocal-based solution with better ergonomics using Swift macros:

// swift-dependencies
struct BuildCommandService {
  @Dependency(.environment) var environment: Environment

  func run() async throws {
      let envVariables = environment.variables
  }
}

// swift-service-context
struct BuildCommandService {
  func run() async throws {
      let envVariables = ServiceContext.current?.environment.variables
  }
}

Note that swift-dependencies requires a default, ensuring you don’t have to deal with optionals. This doesn’t solve the issue where you’ve forgotten to set the correct instance, making the issue a bit trickier to debug since we’d need to spot that a default has been used, but I think the improvement in the API is worth it.

Motivation

Tuist should strive to follow coding patterns that are a joy to use. If you ask me, I don’t think ServiceContext’s pattern is. There’s a camp of developers that believe dependency injection should be explicit. Still, I believe that when it comes to dependencies, a bit of implicitness with the dependencies that we can assume exist throughout an app’s lifecycle is sensible. For example, we can assume a CLI has a set of environment variables we might need to access at any time. Or a function that gives us the current date and time. Why do envVariables: [String: String] appear in every function signature?

This proposal is an incremental step from swift-service-context to increment the ergonomics further. I’d do it soonish before we continue to depend more and more in ServiceContext.current.

Detailed design

I propose that we introduce swift-dependencies and use it to dependency-inject Environment. I’d suggest that we don’t migrate everything to this new pattern, but rather document it and migrate code as we come across it, in the same way we are doing with Mockable and Swift Testing. Long-term we shouldn’t have business logic that depends on global instances such as MyUtil.shared or ProcessInfo.processInfo.environment

Drawbacks

  • Implicit dependency injection is a pattern that’s easy to overuse, seeking convenience at the cost of a lot of implicitness that makes code hard to debug. I suggest we develop guidelines to determine when a dependency should be injected explicitly or implicitly, so we can use them to make decisions.
  • We’ll have two solutions co-existing for some time, leading to some confusion for contributors. Since this won’t be permanent, I think it’s a sensible cost to bear for that period.

Alternatives

  • Continue using ServiceContext. However, as I mentioned earlier, the ergonomics can be better, and I believe we should strive for the best to make contributing to Tuist a joy.
  • Compile-time solutions like the ones listed by swift-dependencies. However, adding build-time or generation-time complexity is something that we learned is not a great idea with a build system that’s already too stretched. This might change in the future, though. In Android, for instance, some dependency injection solutions are integrated as Gradle plugins.

Adoption strategy

I propose that we integrate swift-dependencies, use it to dependency-inject an instance of Environment, and add some guidelines around the differences between explicit and implicit dependency injection and when we should use one over the other.

How we teach this

I propose that we include some guidelines in our contributors’ documentation.

Hey :wave:

Just wanted to drop in here that I’m aligned with the approach here. ServiceContext ergonomics is not great, as you mentioned, and the swift-dependencies is a much better abstraction.

I think we can use raw @TaskLocal instead. Here’s a PR embodying the above proposal.