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
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
andSendable
. 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
}
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
}
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
}
live
The live implementation would use a real services:
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. 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") }
}
}
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()
...
}
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
}
}
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 }
}
}
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
)
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() }))
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
}
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: ...)
We started to fix it by isolating all the Dependencies on MainActor
@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 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" }
}
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 {}
}
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)