DEV Community

Cover image for Control dependencies with structs in Swift
Agapov Alexey
Agapov Alexey

Posted on

Control dependencies with structs in Swift

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.

protocol HTTPClient {}
class URLSessionHTTPClient: HTTPClient {} // live
class SpyHTTPClient: HTTPClient {} // mock
Enter fullscreen mode Exit fullscreen mode

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 where possible.
  • Use 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 library, it has fair points
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
}
Enter fullscreen mode Exit fullscreen mode

Dependencies article example

How we do Dependency Injection in Aviasales

What to try

🖼️ Use Preview and see ModuleDependencies.preview in action

📱 Run application and see ModuleDependencies.live showing real data and handling real logic

🟥 Run tests and see how dependencies are managed. Try removing Dependency setup and see a crash with ModuleDependencies.failing approach

🎛️ Check SetupMainScreenDependencies() method that sets up external dependencies. It is used when module doesn't know about context of usage.

Modules

Simplified module system to show relations and need for external dependencies logic.

flowchart TD
    M[FeatureMainScreen] --> S[Shared]
    SET[FeatureSettings] --> S
    App --> M
    App --> SET






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. It actually is heavily inspired by them, but we're not yet using their up-to-date swift-dependencies library.

struct Dependencies {
  let request: (URL) -> Data
  let token: () -> String
}
Enter fullscreen mode Exit fullscreen mode

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

struct Dependencies {
  let apiToken: () -> String
  let track: (StatisticsEvent) -> Void
}
Enter fullscreen mode Exit fullscreen mode

live

The live implementation would use a real services:

static var live: Dependencies { 
  Dependencies(
    apiToken: { AuthManager.shared.apiToken },
    track: { StatisticsManager.shared.track($0) }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

failing

Failing implementation helps with tests. Read more on swift-dependencies docs. Basically we want to fail tests first. Every test starts with all dependencies set to failing.

static var failing: Dependencies { 
  Dependencies(
    apiToken: unimplemented("Dependencies.apiToken"),
    track: { fatalError("Dependencies.track is not implemented") }
  }
}
Enter fullscreen mode Exit fullscreen mode

unimplemented 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:

func testX() {
  Dependencies = .failing
  Dependencies.apiToken = { "fake" }
  methodThatUsesApiToken()
  ...
}
Enter fullscreen mode Exit fullscreen mode

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. We usually use live implementation here and are able to substitute live with some edge cases.

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

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.

static var noop: Dependencies { 
  Dependencies(
    apiToken: { "" },
    track: { _ in }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
)
Enter fullscreen mode Exit fullscreen mode

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.

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() }))
Enter fullscreen mode Exit fullscreen mode

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

struct Dependencies: Sendable {
  let apiToken: @Sendable () -> String
  let screenSize: @Sendable @MainActor () -> CGSize
  let trackEvent: @Sendable (StatisticsEvent) async -> Void
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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: ...)
Enter fullscreen mode Exit fullscreen mode

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

@MainActor struct ModuleDependencies {}
@MainActor
var Dependencies = dependencies(live: ...)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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:

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 {}
}
Enter fullscreen mode Exit fullscreen mode

Full project on Github:

Dependencies article example

How we do Dependency Injection in Aviasales

What to try

🖼️ Use Preview and see ModuleDependencies.preview in action

📱 Run application and see ModuleDependencies.live showing real data and handling real logic

🟥 Run tests and see how dependencies are managed. Try removing Dependency setup and see a crash with ModuleDependencies.failing approach

🎛️ Check SetupMainScreenDependencies() method that sets up external dependencies. It is used when module doesn't know about context of usage.

Modules

Simplified module system to show relations and need for external dependencies logic.

flowchart TD
    M[FeatureMainScreen] --> S[Shared]
    SET[FeatureSettings] --> S
    App --> M
    App --> SET






Someday we will adopt 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

Top comments (0)