Refactoring a networking layer to use Combine

Published on: January 20, 2020

In the past two weeks I have introduced you to Combine and I've shown you in detail how Publishers and Subscribers work in Combine. This week I want to take a more practical route and explore Combine in a real-world setting. A while ago, I published a post that explained how you can architect and build a networking layer in Swift without any third-party dependencies. If you haven't seen that post before, and want to be able to properly follow along with this post, I recommend that you skim over it and examine the end result of that post.

This week, I will show you how to refactor a networking layer that uses closures to a networking layer that's built with Combine. By the end of this post you will know and understand the following topics:

  • Converting an API that uses callbacks to one that uses Combine publishers.
  • Using Combine's built-in features to fetch data and decode it.
  • Implementing error handling in Combine.

Once you understand how you can refactor a networking client to use Combine, you should have a very rough idea of how to convert any callback-based API to use Combine.

Converting an API that uses callbacks to one that uses Combine publishers

In the networking API that we're refactoring, the full API is defined using a couple of protocols. When you're embarking on a refactoring adventure like this, it's a good idea to start by changing the protocols first and update the implementations later. The protocols that we're refactoring look as follows:

protocol RequestProviding {
  var urlRequest: URLRequest { get }
}

protocol APISessionProviding {
  func execute<T: Decodable>(_ requestProvider: RequestProviding, completion: @escaping (Result<T, Error>) -> Void)
}

protocol PhotoFeedProviding {
  var apiSession: APISessionProviding { get }

  func getPhotoFeed(_ completion: @escaping (Result<PhotoFeed, Error>) -> Void)
}

The protocols above define a very simple and basic networking layer but it's just complex enough for us to refactor. The RequestProviding protocol can remain as-is. It doesn't use callbacks so we don't have to convert it to use Combine. We can, however, refactor the ApiSessionProviding and the PhotoFeedProviding protocols. Let's start with the ApiSessionProviding protocol:

protocol APISessionProviding {
  func execute<T: Decodable>(_ requestProvider: RequestProviding) -> AnyPublisher<T, Error>
}

The execute(_:) function no longer accepts a completion parameter, and it returns AnyPublisher<T, Error> instead. Note that we're not using URLSession.DataTaskPublisher<T, Error> or a different specific Publisher object. Instead, we use a very generic publisher that's prefixed with Any. Using AnyPublisher is quite common in Combine for reasons I will explain in the next section. For now, it's important to understand that AnyPublisher is a generic, type erased, version of a Publisher that behaves just like a regular Publisher while hiding what kind of publisher it is exactly.

For the PhotoFeedProviding protocol we're going to apply a similar refactor:

protocol PhotoFeedProviding {
  var apiSession: APISessionProviding { get }

  func getPhotoFeed() -> AnyPublisher<PhotoFeed, Never>
}

The refactor for the getPhotoFeed() function is pretty much the same as the one we did earlier. We removed the completion closure and the function now returns an AnyPublisher. This time it's an AnyPublisher<PhotoFeed, Never> because we don't need getPhotoFeed() to be generic. We also don't want our publisher to publish any errors to the caller of getPhotoFeed(). This means that we'll need to handle any errors that might occur in the APISessionProviding object, and we need to do something to make sure we always end up with a valid PhotoFeed instance.

By redefining the protocols, we have refactored the API because the protocols define what the API looks like. The next step is to refactor the objects that implement these protocols.

Using Combine's built-in features to fetch data and decode it

In an earlier post, I have already shown you how you can make a network call with Combine. But I didn't quite explain what that code does, or how it works exactly. In this week's post, I will go over networking in Combine step by step so you know exactly what the code does, and how it works. Let's get started with the first step; making a network call:

struct ApiSession: APISessionProviding {
  func execute<T>(_ requestProvider: RequestProviding) -> AnyPublisher<T, Error> where T : Decodable {
    return URLSession.shared.dataTaskPublisher(with: requestProvider.urlRequest)
  }
}

The code above does not compile yet and that's okay. It takes a couple more steps to get the code to a place where everything works. The execute method returns a publisher so that other objects can subscribe to this publisher, and can handle the result of the network call. The very first step in doing this is to create a publisher that can actually make the network call. Since dataTaskPublisher(with:) returns an instance of URLSession.DataTaskPublisher, we need to somehow convert that publisher into another publisher so we can eventually return AnyPublisher<T, Error>. Keep in mind that T here is a Decodable object and our goal is to take the data that's returned by a network call and to decode this data into a model of type T.

To do this, we can use the decode(type:decoder:) operator that Combine provides. We can't use this operator directly because decode(type:decoder:) can only be used on publishers that have an Output of Data. To do this, we can map the output of the data task publisher and feed the result of this map operation to the decode operation:

struct ApiSession: APISessionProviding {
  func execute<T>(_ requestProvider: RequestProviding) -> AnyPublisher<T, Error> where T : Decodable {
    return URLSession.shared.dataTaskPublisher(for: requestProvider.urlRequest)
      .map { $0.data }
      .decode(type: T.self, decoder: JSONDecoder())
  }
}

The code above takes the Output of the URLSession.DataTaskPublisher which is (data: Data, response: URLResponse) and transforms that into a publisher whose Output is Data using the map operator. The resulting publisher is then transformed again using the decode(type:decoder:) operator so we end up with a publisher who's output is equal to T. Great, right? Not quite. We get the following compiler error at this point:

Cannot convert return expression of type 'Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, T, JSONDecoder>' (aka 'Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, Data>, T, JSONDecoder>') to return type 'AnyPublisher<T, Error>'

Examine the error closely, in particular, focus on the type that we're trying to return from the execute(_:) method:

Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, T, JSONDecoder>

This type is a publisher, wrapped in another publisher, wrapped in another publisher! Whenever we apply a transformation in Combine, the transformed publisher is wrapped in another publisher that takes the upstream publisher as its input. While the chain of operations above does exactly what we want, we don't want to expose this complex chain of publishers to callers of execute(_:) through its return type. In order to hide the details of our publisher chain, and to make the return type more readable we must convert our publisher chain to a publisher of type AnyPublisher. We can do this by using the eraseToAnyPublisher after the decode(type:decoder:) operator. The final implementation of execute(_:) should look as follows:

struct ApiSession: APISessionProviding {
  func execute<T>(_ requestProvider: RequestProviding) -> AnyPublisher<T, Error> where T : Decodable {
    return URLSession.shared.dataTaskPublisher(for: requestProvider.urlRequest)
      .map { $0.data }
      .decode(type: T.self, decoder: JSONDecoder())
      .eraseToAnyPublisher()
  }
}

The code above does not handle any errors. If an error occurs at any point in this chain of publishers, the error is immediately forwarded to the object that subscribes to the publisher that's returned by execute(_:). Alternatively, we can implement some error handling using more operators to make sure that our subscribers never receive any errors. We'll implement this in the next section by adding more operators in the getPhotoFeed() method.

Implementing error handling in Combine

There are several ways to handle and emit errors in Combine. For example, if you want to use a map operator, and the mapping operation can throw an error, you can use tryMap instead. This allows you to easily send any errors that are thrown down the publisher chain. Most, if not all, of Combine's operators, have a try prefixed version that allows you to throw errors instead of handling them. In this section, we are not interested in throwing errors, but we want to catch them and transform them into something useful. Let's look at the implementation of the FeedProvider that implements the getPhotoFeed() method:

struct PhotoFeedProvider: PhotoFeedProviding {
  let apiSession: APISessionProviding

  func getPhotoFeed() -> AnyPublisher<PhotoFeed, Never> {
    return apiSession.execute(FeedService.photoFeed)
      .catch { error in
        return Just(PhotoFeed())
      }.eraseToAnyPublisher()
  }
}

Just like before, we want to return an AnyPublisher. The difference here is that we're returning a publisher that can't produce an error. To turn a publisher that might produce an error (AnyPublisher<PhotoFeed, Error> in this case) into a publisher that doesn't produce an error, we need to implement error handling. The easiest way to implement this is with the catch operator. This operator is only used if the publisher it's applied to produces an error, and you must return a new publisher that has Never as its error type from this operator. In this case, we use Combine's Just publisher to create a publisher that immediately completes with the value that's supplied to its initializer. In this case, that's an empty instance of PhotoFeed. Because the Just publisher completes immediately with the supplied value, it can never produce an error, and it's a valid publisher to use in the catch operator.

If we would want to make an attempt to handle the error but anticipate that we might fail to do so adequately, the tryCatch operator can be used. Similar to tryMap, this is an operator that follows the same rules as catch, except it can throw an error. The tryCatch operator is especially useful if you have fallback logic that would load data from a cache if possible, and throw an error if loading data from cache fails too.

Just like before, we need to use eraseToAnyPublisher() to transform the result of the catch operator to AnyPublisher to avoid having to write Publishers.Catch<AnyPublisher<PhotoFeed, Error>, Publisher> as our return type.

This wraps up all the work we need to do to implement the networking layer from my earlier post to one that uses Combine. We can use this networking layer as follows:

let session = ApiSession()
let feedProvider = PhotoFeedProvider(apiSession: session)
let cancellable = feedProvider.getPhotoFeed().sink(receiveValue: { photoFeed in
  print(photoFeed)
})

Nice and compact! And notice that we don't have to handle any completion or error events in the sink because getPhotoFeed() is guaranteed to never produce an error. Of course, never returning an error to the caller of your networking layer might not be feasible in your app. In this example, I wanted to show you how you can take a publisher that might produce an error and convert it to a publisher that is guaranteed to never produce errors.

In summary

Even though you haven't written a ton of code in this week's post, you have learned a lot. You learned how you can take a callback-based API and convert it to use Combine. We started by converting some existing protocols that describe a networking layer and from there we worked our way to refactoring some code and we even looked at error handling in Combine. The code itself is of course tailored for the networking layer we wanted to refactor, but the principles that I've demonstrated can be applied to any callback-based API.

The networking layer that we refactored is in many ways more concise, expressive and simple than the code we had before. However, stacking many transformations in Combine can lead to code that is hard to maintain and reason about. Because we can do a lot with very little code, it can be tempting to write functions that do much more that you would like in an application that does not use FRP at all. All I can say about this is that you should use your best judgment and apply new abstractions or intermediate functions, or even custom Combine transformations, where needed.

If you have any questions about this week's post, or if you have feedback for me, don't hesitate to reach out on Twitter.

Categories

Combine Networking

Subscribe to my newsletter