Macro Bonanza: Dependencies

Thursday November 16, 2023
Preamble

To celebrate the release of Swift macros we releasing updates to 4 of our popular libraries to greatly simplify and enhance their abilities: CasePaths, ComposableArchitecture, SwiftUINavigation, and Dependencies. Each day this week we will detail how macros have allowed us to massively simplify one of these libraries, and increase their powers.

Today we are releasing version 1.1 of our popular Dependencies library, which introduces a new @DependencyClient macro for making it easier to design your dependencies. The library now provides a complete toolkit for designing and controlling your dependencies, and makes it easy to preview and test your features in isolation.

Designing dependencies

While our Dependencies library is just a dependency management library, we do highly encourage users of the library to design their dependencies in a specific way. We recommend avoiding protocols for modeling the interfaces of things like API clients, database clients, audio players, and more, for a few reasons:

  • These interfaces tend to have a very specific, limited set of conformances, typically just live and test implementations. They are not similar to the popular protocols in the Swift ecosystem, such as Collection, View, Reducer, and others, which may have hundreds or thousands of conformances.

  • One does not typically use the full power of protocols with these kinds of interfaces, such as primary associated types, operators for transforming conformances, static type preservation, and more.

  • For dependencies in particular, it can be useful to override a single endpoint in tests while leaving all other endpoints unimplemented. This gives you exhaustive test coverage on exactly what parts of your dependencies are used in a particular execution flow.

For these reasons, and more, we think using structs with closure properties to model dependencies is typically a far better choice. We have talked extensively about it in episodes and we wrote about it in the documentation for our Dependencies library.

And so rather than designing the interface of an audio player dependency like this:

protocol AudioPlayer {
  func loop(url: URL) async throws
  func play(url: URL) async throws
  func setVolume(_ volume: Float) async
  func stop() async
}

…we recommend designing it like this:

struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

The main benefit to this style of dependency is that you get the ability to individually override the endpoints of the client. The biggest use case of this is in tests and previews, where you can start your client in a kind of default, “mocked” state where each endpoint does nothing. And then selectively override the endpoints that you think will be used in the test or preview.

For example, it is common to maintain an “unimplemented” version of the dependency that simply causes an XCTest failure when invoked. We even ship a tool called unimplemented that helps with this:

extension AudioPlayerClient {
  static let unimplemented = Self(
    loop: unimplemented("loop"),
    play: unimplemented("play"),
    setVolume: unimplemented("setVolume"),
    stop: unimplemented("stop"),
  )
}

Then you can start with an unimplemented client and override the endpoints you think will be used. If you were testing the flow of someone starting and stopping the audio player, you could do so like this:

let player = AudioPlayerClient.unimplemented
player.play = { _ in }
player.stop = { }
let model = Feature(player: player)
model.playButtonTapped()
model.stopButtonTapped()

If this test passes, then it definitively proves that loop and setVolume were not invoked in your feature, because otherwise the test would have failed.

So, we think struct interfaces are a great way of modeling dependencies, but they do have one unfortunate consequence. And that is that you lose argument labels. The play endpoint, because it’s just a closure, must be invoked like this:

player.play(URL(…))

…and not like this:

player.play(url: URL(…))

That is unfortunate, but luckily we can fix this problem and a whole lot more…

Introducing the @DependencyClient macro

The @DependencyClient macro can be applied to any dependency interface built in the “struct-of-closures” style. Using the AudioPlayerClient from above, we can simply do:

import DependenciesMacros

@DependencyClient
struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

That one change comes with lots of benefits. First of all, it provides a default to each endpoint that simply throws an error and triggers an XCTest failure. This means the unimplemented instance we defined now comes for free by applying the macro:

 extension AudioPlayerClient {
-  static let unimplemented = Self(
-    loop: unimplemented("loop"),
-    play: unimplemented("play"),
-    setVolume: unimplemented("setVolume"),
-    stop: unimplemented("stop"),
-  )
+  static let unimplemented = Self()
 }

Further, when the closures in the client are provided with argument labels, a corresponding method is added to the client with proper argument labels:

let client = AudioPlayerClient()
client.play(url: URL(…))

This greatly improves the ergonomics of invoking endpoints on the client.

And finally, when separating the interface and implementation of dependencies into separate modules (see here for more information), one is forced to define an initializer on the client struct so that it can be created outside the module. This is just a Swift limitation in general, and not related to the struct-of-closures style of dependency design, but it is annoying nonetheless.

This is a common use case for management dependencies, and that is why we have made the @DependencyClient automatically generate this initializer for you. This means in a different module you can immediately create an AudioPlayerClient with no additional work:

extension AudioPlayerClient: DependencyKey {
  static let liveValue = AudioPlayerClient(
    loop: { _ in }
    play: { _ in }
    setVolume: { _ in }
    stop: { }
  )
}

@DependencyClient in practice

We have used this macro to massively clean up the code in our open-source word game, isowords, as well as the code that powers this very site, which is open-source and completely written in Swift. Each of those code bases have multiple large and complex dependencies which we had to manually maintain both a public initializer and testValue, which meant that each time we added a new feature to the dependency we had multiple places in our code we had to update.

For example, in the Point-Free codebase we have a database client that has over 50 endpoints for making various queries on this site. Previously we had to maintain a public initializer for this client so that it could be constructed outside its module. And we maintained a “failing” version of the client that triggered an XCTest failure for each endpoint. This was useful for exhaustively testing features and explicitly proving which database endpoints were used in a specific user flow, but this was a ton of code to maintain and a huge pain.

By appling the @DependencyClient macro to the database interface we can now delete all of that code, and anytime we add a new endpoint to our database we will not have to update any existing code. And we get nice methods with argument labels automatically:

-try await database.addUserIdToSubscriptionId(
-  currentUser.id, 
-  subscription.id
-)
+try await database.addUser(
+  id: currentUser.id, 
+  toSubscriptionID: subscription.id
+)

This gives us important information about the dependency endpoint so that we don’t accidentally mix something up.

Get started today

Starting using the @DependencyClient macro today by updating or adding Dependencies 1.1 to your project today. It can help you write safer application code and stronger tests with less code.

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing