Designing Dependencies: Modularization

Episode #111 • Aug 3, 2020 • Subscriber-Only

Let’s scrap the protocols for designing our dependencies and just use plain data types. Not only will we gain lots of new benefits that were previously impossible with protocols, but we’ll also be able to modularize our application to improve compile times.

Modularization
Introduction
00:05
Modularizing a dependency
08:03
Extracting dependencies to packages
11:03
Interface vs. implementation
15:11
Isolating features from live dependencies
23:55
Next time: a long-living dependency
33:28

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

And so this one single conformance seems to have replaced the previous 3 conformances, and so that seems like a win. But again, we are back to the situation where we only have two conformances for this protocol: a live one and a mock one. And the mock implementation requires quite a bit of boilerplate to make it versatile.

Turns out we have essentially just recreated a technique that we have discussed a number of times on Point-Free, first in our episodes on dependency injection (made simple, made comfortable) and then later in our episodes on protocol witnesses. For the times that a protocol is not sufficiently abstracting away some functionality, which is most evident in those cases where we only have 1 or 2 conformances, it can be advantageous to scrap the protocols and just use a simple, concrete data type. That is basically what this MockWeatherClient type is now.

So, let’s just take this all the way. Let’s comment out the protocol:

//protocol WeatherClientProtocol {
//  func weather() -> AnyPublisher<WeatherResponse, Error>
//  func searchLocations(coordinate: CLLocationCoordinate2D)
//    -> AnyPublisher<[Location], Error>
//}

This will break some things we need to fix. First we have the live weather client, the one that actually makes the API requests. We are going to fix this in a moment so let’s skip it for now.

Next we have the MockWeatherClient. Instead of thinking of this type as our “mock” we are now going to think of it as our interface to a weather client’s functionality. One will construct instances of this type to represent a weather client, rather than create types that conform to a protocol.

So, we are going to get rid of the protocol conformance, and we can even get rid of the methods and underscores:

struct WeatherClient {
  var weather: () -> AnyPublisher<WeatherResponse, Error>
  var searchLocations: (CLLocationCoordinate2D) -> AnyPublisher<[Location], Error>
}

And now, instead of creating conformances of the WeatherClient protocol, we will be creating instances of the WeatherClient struct.

We can first create the “live” version of this dependency, which is the one that actually makes the API requests. We don’t current need the searchLocations endpoint so I’ll just use a fatalError in there for now:

extension WeatherClient {
  static let live = Self(
    weather: {
      URLSession.shared
        .dataTaskPublisher(for: URL(string: "https://www.metaweather.com/api/location/2459115")!)
        .map { data, _ in data }
        .decode(type: WeatherResponse.self, decoder: weatherJsonDecoder)
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    },
    searchLocations: { coordinate in
      fatalError()
    }
  )
}

We can recreate the other 3 conformances of the protocol by simply creating instances of this type. A natural place to house these values is as statics inside the WeatherClient type:

extension WeatherClient {
  static let empty = Self(
    weather: {
      Just(WeatherResponse(consolidatedWeather: []))
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    }, searchLocations: { _ in
      Just([])
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    })

  static let happyPath = Self(
    weather: {
      Just(
        WeatherResponse(
          consolidatedWeather: [
            .init(applicableDate: Date(), id: 1, maxTemp: 30, minTemp: 10, theTemp: 20),
            .init(applicableDate: Date().addingTimeInterval(86400), id: 2, maxTemp: -10, minTemp: -30, theTemp: -20)
          ]
        )
      )
      .setFailureType(to: Error.self)
      .eraseToAnyPublisher()
    }, searchLocations: { _ in
      Just([])
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    })

  static let failed = Self(
    weather: {
      Fail(error: NSError(domain: "", code: 1))
        .eraseToAnyPublisher()
    }, searchLocations: { _ in
      Just([])
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    })
}

Only a few more errors to fix. Next is in the view model’s initializer, where we are explicitly requiring that whoever creates the view model must pass in a conformance to the WeatherClientProtocol. Well, now we can require just a WeatherClient and we can default it to the live one:

init(isConnected: Bool = true, weatherClient: WeatherClient = .live) {

And finally in our SwiftUI preview, instead of constructing a MockWeatherClient from scratch we can just use the live one:

ContentView(
  viewModel: AppViewModel(
    weatherClient: .live
  )
)

Or we could use any of the other ones too:

//weatherClient: .live
//weatherClient: .happyPath
weatherClient: .failed

We can even do fun stuff like start with the happy path client, and then change one of the endpoints to be a failure:

var client = WeatherClient.happyPath
client.searchLocations = { _ in
  Fail(error: NSError(domain: "", code: 1))
    .eraseToAnyPublisher()
}
return client

This kind of transformation is completely new to this style of designing this dependency, and was not easily done with the protocol style.

We could even just open up a scope to make a custom client based off the live client right inline:

weatherClient: {
  var client = WeatherClient.live
  client.searchLocations = { _ in
    Fail(error: NSError(domain: "", code: 1))
      .eraseToAnyPublisher()
  }
  return client
}()

It’s a little noisy, but it’s also incredibly powerful. It is super easy to create all new weather clients from existing ones, and that just isn’t possible with protocols and their conformances. And we have seen this many times in the past, such as when we developed a snapshot testing library in this style and could create all new snapshot strategies from existing ones, allowing us to build up a complex library of strategies with very little work.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

References

Protocol-Oriented Programming in Swift

Apple • Tuesday Jun 16, 2015

Apple’s eponymous WWDC talk on protocol-oriented programming:

At the heart of Swift’s design are two incredibly powerful ideas: protocol-oriented programming and first class value semantics. Each of these concepts benefit predictability, performance, and productivity, but together they can change the way we think about programming. Find out how you can apply these ideas to improve the code you write.

Collection: Protocol Witnesses

Brandon Williams & Stephen Celis

Protocols are great! We love them, you probably love them, and Apple certainly loves them! However, they aren’t without their drawbacks. There are many times that using protocols can become cumbersome, such as when using associated types, and there are some things that are just impossible to do using protocols. We will explore some alternatives to protocols that allow us to solve some of these problems, and open up whole new worlds of composability that were previously impossible to see.