Control dependencies with structs in Swift
Tags: ios, swift, macos, dependencyinjection
Published: 2024-04-03 04:31:35

Dependencies are essential elements of your codebase. They allow us to delegate tasks, improve modularity, and replace certain components in tests. Adding dependency injection helps us achieve the ultimate goal: **easy and consistent replacement of code implementation**.

```swift
protocol HTTPClient {}
class URLSessionHTTPClient: HTTPClient {} // live
class SpyHTTPClient: HTTPClient {} // mock
```

Swift developers are familiar with libraries to manage dependencies, like Swinject or Needle. They can easily be found on Github. However, my question is: do these libraries truly lead your project toward the ultimate goal of efficient implementation replacement? To some extent, they do, but at the cost of adding a framework that infects your entire codebase.

Some developers opt for methods that don't require frameworks: introducing a Service Locator and initializing everything with a parent protocol or class. But are these methods ergonomic?

---

## TL;DR

- Utilize [struct-based dependencies](https://www.pointfree.co/blog/posts/21-how-to-control-the-world) where possible.
- Use [LockIsolated](https://github.com/pointfreeco/swift-concurrency-extras?tab=readme-ov-file#actorisolated-and-lockisolated) for global variables if you're ok with lock overhead and want sync calls. This fixes warning `// Var '...' is not concurrency-safe because it is non-isolated global shared mutable state; this is an error in Swift 6`
- Combine ﻿`MainActor` and `﻿Sendable`. Resort to isolating code on any actor as a last option, but it might be unavoidable with UIKit or View layer APIs.
- Look into struct-based approach and consider reading docs on [pointfreeco/swift-dependencies](https://github.com/pointfreeco/swift-dependencies) library, it has fair points

```swift
struct Dependencies: Sendable {
  let apiToken: @Sendable () -> String
  let screenSize: @Sendable @MainActor () -> CGSize
  let trackEvent: @Sendable (StatisticsEvent) async -> Void
  var external: ExternalDependencies!
}
struct ExternalDependencies: Sendable {
  let route: @Sendable (Route) -> Void
}
```

{% embed https://github.com/AgapovOne/example-Dependencies %}

---

## Struct-based World

At Aviasales, we have adopted struct-based dependencies instead of the classic protocol-oriented approach. This shift has enhanced modularity in our projects. You might recognize this methodology as [Pointfree approach with the World pattern](https://www.pointfree.co/blog/posts/21-how-to-control-the-world). It actually is heavily inspired by them, but we're not yet using their up-to-date [swift-dependencies library](https://github.com/pointfreeco/swift-dependencies).
 
```swift
struct Dependencies {
  let request: (URL) -> Data
  let token: () -> String
}
```

We don't create mocks with some library and codegen. Why should we, if replacing one variable without affecting everything else is so easy? This is not possible with the plain protocol-oriented way.

We don't create protocols when we don't need generic implementation. Why we need it if our dependency is a simple `() -> String` provider?

We control all the dependencies for one module/feature/unit with a simple struct that can do _almost_ everything we want.

---

We have created some rules on how to live with these dependencies in a code where we have previews and tests. Swift Concurrency changed the way we create and access the dependencies. I'll try to explain and provide examples.

## Create live and provide failing, noop and preview

Let's discuss an example of dependencies

```swift
struct Dependencies {
  let apiToken: () -> String
  let track: (StatisticsEvent) -> Void
}
```

### live

The live implementation would use a real services:

```swift
static var live: Dependencies { 
  Dependencies(
    apiToken: { AuthManager.shared.apiToken },
    track: { StatisticsManager.shared.track($0) }
  }
}
```

But we also need failing, noop, and preview implementations. Why?

### failing

Failing implementation helps with tests. Read more on [swift-dependencies docs](https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/livepreviewtest#Test-value). Basically we want to fail tests first. Every test starts with all dependencies set to failing.

```swift
static var failing: Dependencies { 
  Dependencies(
    apiToken: unimplemented("Dependencies.apiToken"),
    track: { fatalError("Dependencies.track is not implemented") }
  }
}
```

[unimplemented](https://pointfreeco.github.io/xctest-dynamic-overlay/main/documentation/xctestdynamicoverlay/unimplemented(_:fileid:line:)-5098a/) or fatalError does the same job. It fails test if dependency was not set but was accessed in code.

We use `unimplemented` because of better log with file name and multiple arguments support.

The test would look like this:

```swift
func testX() {
  Dependencies = .failing
  Dependencies.apiToken = { "fake" }
  methodThatUsesApiToken()
  ...
}
```

We **control** dependencies. They're not defined in the method, we don't provide dependencies in arguments, but we use those dependencies somewhere inside this unit. And they still can be replaced.

### preview

The next one is preview. It appeared when we started using demo apps in a module and previews with SwiftUI. Those dependencies are set by default when code is running [in preview environment](https://sarunw.com/posts/detecting-xcode-previews/). We usually use live implementation here and are able to substitute live with some edge cases.

```swift
static var preview: Dependencies { 
  Dependencies(
    apiToken: Dependencies.live.apiToken, // same as live to use real networking
    track: { print("event is tracked \($0)") } // simple debug
  }
}
```

We called it `mock` before, but actually `mock` doesn't say anything about usage context. If it does nothing, it's `noop`. If it does something real, it's `live`. And if it's used for demo/debug purposes - it's `preview`. So we changed `mock` naming to `preview`. It matches common knowledge of SwiftUI Previews.

### noop

A `no operation`. Often called fake, mock, or stub. We provide noop, cause it actually _does nothing_. Empty api token, but it's there, not optional, not throwing function. Empty implementation of event tracking. It doesn't crash, but also it doesn't create any side effects.

```swift
static var noop: Dependencies { 
  Dependencies(
    apiToken: { "" },
    track: { _ in }
  }
}
```

We match the interface, we satisfy the compiler. That's all there is to it.

### access and replacement

We have a global variable for dependencies. Yes, a global. A singleton. The one and only `var Dependencies`.

```swift
var Dependencies = dependencies(
  live: .live, // if we're in application
  preview: .preview, // if we're in previews environment
  failing: .failing // if we're in xctest
)
```

## Flat module structure. External

To allow parallel builds we are using flat module structure that has features(views) on lower level and product(flow) above them. The picture is bigger actually, but that's not the point of this article.

- FlowX
  - FeatureA
  - FeatureB
- FlowY
  - FeatureC

Each module has its own dependencies but sometimes it has to delegate control. It's a usual practice when we have shared interface but injected implementation. 

Consider an example with route methods. Feature module doesn't need to know how it acts in app navigation, so it gives control above, where some kind of controller decides where to navigate.

```swift
struct Dependencies {
  let apiToken: () -> String
  var external: ExternalDependencies!
}
public struct ExternalDependencies {
  let route: (Route) -> Void
}
public func SetupFeatureA(_ external: ExternalDependencies) {
  Dependencies.external = external
}

// somewhere above
SetupFeatureA(ExternalDependencies(route: { navigateToFeatureB() }))
```

We unite all modules with that way of dependencies setup.

## Hello, Swift Concurrency

We thought everything was fine until we had to isolate code, use async and provide Sendable everywhere. Long time passed until we found the new way of coping with all the problems and compiler nuances. 

Let's create a new example implementation with use of MainActor, Sendable and async methods

```swift
struct Dependencies: Sendable {
  let apiToken: @Sendable () -> String
  let screenSize: @Sendable @MainActor () -> CGSize
  let trackEvent: @Sendable (StatisticsEvent) async -> Void
}
```

That's the current code of dependencies declaration. It satisfies compiler because we're using Sendable everywhere and not isolate it on any actor until it is necessary. And it is sometimes necessary with UIKit, for example. We're combining MainActor isolation with no isolation inside our Dependencies struct defined on module. Everything works fine until we examine the Dependencies [global variable](https://www.avanderlee.com/concurrency/concurrency-safe-global-variables-to-prevent-data-races/).

```swift
// Var 'Dependencies' is not concurrency-safe because it is non-isolated global shared mutable state; this is an error in Swift 6

var Dependencies = dependencies(live: ...)
```

We started to fix it by isolating all the Dependencies on `MainActor`

```swift
@MainActor struct ModuleDependencies {}
@MainActor
var Dependencies = dependencies(live: ...)
```

That's not the proper way, because we would change context on every call, even when we don't want to. It can impact performance in the context of Swift Concurrency. Still, it is a possible solution because almost every call is started on MainActor in a View/ViewModel layer with user input. 

Efficient solution requires more work than just isolating everything on MainActor.

Global variable should be immutable. Fine. We've learned a lot about synchronization techniques and have realized that a simple lock is all we need. It's efficient and has the simplest interface possible. No queues, no barriers, and no overhead. [LockIsolated](https://github.com/pointfreeco/swift-concurrency-extras?tab=readme-ov-file#actorisolated-and-lockisolated) is our superhero. It makes dependencies appear immutable for compiler while it provides a way to access it in synchronous context. We still can mutate a value in a synchronous context too.

```swift
let Dependencies = LockIsolated(dependencies(live: ...))

// access
Dependencies.value.apiToken()
// or using dynamic access
Dependencies.apiToken()
// mutation
Dependencies.withValue {
  $0.apiToken = { "fake" }
}
```

All sync, no overhead, still simple and efficient. Yes, we use a lock. There are other options to avoid race conditions and isolate mutable state, but it's still a win option for us.

---

Let's recap all that I've discussed.

The goal was: **easy and consistent replacement of code implementation**.

_Can we replace dependencies?_

**Yes**, with a simple call that can change one property of `Dependencies` struct keeping other properties the same.

_Is it easy?_

That's a subjective question. But we're keeping our implementation approachable if you know how to manage structs. It's not a third-party framework, but requires knowledge of reasons why we're doing each step.

_Is it simple?_

That's different than easy. _Simple_ is opposite of complex. And we're not dependent on any third-party framework with it's caveats and decisions, we're **in control**. And still it's just functions, closures and structs. Basic structures of Swift, nothing fancy with generics or compiler/runtime magic.

_Is it ergonomic?_

Not quite, because we don't use macroses to generate failing implementation. We have to write them ourselves. But we don't need to wait for any tool or library to generate us a complex spy with expectations. We're managing these xcasserts in-place. We also have an Xcode template to generate a simple implementation of `Dependencies` struct and the `SetupX` method.

Whole `Dependencies` example:

```swift
struct ModuleDependencies: Sendable {
  let apiToken: @Sendable () -> String
  let screenSize: @Sendable @MainActor () -> CGSize
  let trackEvent: @Sendable (StatisticsEvent) async -> Void
  var external: ModuleExternalDependencies!
}
struct ModuleExternalDependencies: Sendable {
  let route: @Sendable (Route) -> Void
}

let Dependencies = LockIsolated(dependencies(
    live: ModuleDependencies.live,
    preview: .preview,
    failing: .failing
))
public func SetupSharedDeeplinks(_ external: ModuleExternalDependencies) {
    Dependencies.withValue { $0.external = external }
}

extension ModuleDependencies {
  static var live: Self {}
  static var preview: Self {}
  static var failing: Self {}
}
extension ModuleExternalDependencies {
  static var live: Self {}
  static var preview: Self {}
  static var failing: Self {}
}
```

Full project on Github:

{% embed https://github.com/AgapovOne/example-Dependencies %}

---

Someday we will adopt [swift-dependencies](https://github.com/pointfreeco/swift-dependencies) library. But that is another story :)

Me on Github: https://github.com/AgapovOne

Subscribe on my Twitter: https://twitter.com/agapov_one

My channel in Russian on Telegram: https://t.me/agposdev