Architecting a robust networking layer with protocols
Published on: December 19, 2019Both networking and protocols are topics that I could write dozens of posts on, and I would still have more ideas and examples left in my head. In today's article, I would like to combine the topics of networking and protocols and explain how you can design a robust networking layer for your app, that's 100% testable and mockable because everything down to the URLRequest
is abstracted behind a protocol.
Defining our goals
Any time you're getting ready to write code, you should define your goals first. What are you writing? What problems are you trying to solve? This is especially true when you're getting ready to design APIs for objects that you're going to use all over your code. A good example of this is a networking layer.
Chances are that whatever solution you come up with for your networking layer will be used by many different objects in your code, and if you're part of a team the number of developers that will use your API will probably be roughly equal to the number of developers on your team.
In this article, we're designing a networking API. The goal here is to abstract the networking layer in such a way that we can easily use, reuse and arguably most importantly, test all code we write. I always like working from the outside in when I design code. In other words, I think about how I want to use a new API I'm building rather than thinking about how I want to implement it.
Using this approach, I might sometimes start with a very bare design. For example, I might start by writing the following code:
class FeedViewModel {
let service: FeedProviding
var feed: Feed?
var onFeedUpdate: () -> Void = {}
init(service: FeedProviding) {
self.service = service
}
func fetch() {
service.getFeed { result in
do {
self.feed = try result.get()
self.onFeedUpdate()
} catch {
// handle error
}
}
}
}
I want you to focus on the service
that's used here. It's a simple service that has a getFeed(_:)
method and calls a completion closure with a result of type Result<Feed, Error>
. At this point, our goal is clear enough. We need to design an easy to use service that can be injected into a view model so we can fetch a Feed
object.
Implementing the networking layer
Since we're working our way from the outside in, we're going to start working at the service level and work our way down to the networking layer from there. Every time we come up with a new object, we're going to define it as a protocol first. You'll see that doing this allows you to write code that's highly testable, flexible and focussed. Let's write a protocol definition for the FeedProviding
service from the previous section:
protocol FeedProviding {
func getFeed(_ completion: @escaping (Result<Feed, Error>) -> Void)
}
Simple enough, right? It's just a protocol that exposes the getFeed(_:)
method that we saw earlier. But of course, that's not enough. Let's write a simple implementation of getFeed(_:)
:
extension FeedProviding {
func getFeed(_ completion: @escaping (Result<Feed, Error>) -> Void) {
network.fetch(.feed, completion: completion)
}
}
Even though the API looks simple, there's a lot to unpack here!
The getFeed(_:)
method is not defined on an object that conforms to FeedProviding
. Instead, it's implemented as an extension of FeedProviding
itself. In Swift, it's possible to write extensions for protocols to give them default behaviors and functionality. Since we don't have any reason to assume that we'll need multiple feed providers (other than a mock one in unit tests) it's a fine idea to implement this method in a protocol extension. Any objects that want conform to FeedProviding
can implement their own getFeed(_:)
method that will be called instead of the one defined in the protocol extension.
The getFeed(_:)
implementation that's written here uses a network
object. And that object had a fetch(_:completion:)
method that we pass an enum value, or a static property of something. We don't know what it will be at this point. All that's decided is that it's something that will inform the network of the endpoint it has to fetch. The completion closure that's passed to the getFeed(_:)
method is passed on to the fetch(_:completion:)
method directly. This implies that once the network call succeeds, the Data
from the response is decoded into a Feed
object automatically.
You might be wondering why we should bother with this method and protocol at all. We might just as well either skip the service object and use a networking object directly in the view model. Or we could just call service.network.fetch(_:completion:)
from the view model. The reason we need a service object in between the network and the view model is that we want the view model to be data source agnostic. What this means is that the view model shouldn't care where it gets data from, if the service decides that it will cache network responses, it should be able to do so transparently; the view model shouldn't be aware of this. Calling out to the service's networking object directly from the view model is not a great idea for similar reasons. It would also violate the Law of Demeter.
Further reading available:
Back to business, based on the few lines of code in the extension we added to FeedProviding
we now have three new goals:
- The networking object should accept some kind of endpoint or request configuration object.
- The networking object's
fetch(_:completion:)
should decode data into an appropriate model. - Any object that implements
FeedProviding
requires a networking object.
To meet the first two requirements in one go, we can define the following protocol:
protocol Networking {
func fetch<T: Decodable>(_ endpoint: Endpoint, completion: @escaping (Result<T, Error>) -> Void)
}
By making fetch(_:completion:)
generic over a Decodable
object T
, we achieve an extremely high level of flexibility. The service layer can define what the Networking
object will decode its data into because Swift will infer T
based on the completion
closure that is passed to fetch(_:completion:)
. The fetch method also accepts an endpoint
parameter that's of type Endpoint
. This could either be a struct or an enum, depending on your needs. In this case, I'm going to go with an enum because at this point I'm pretty sure I'm not going to need any instances of Endpoint
, I'm only going to need the endpoint identifiers in the form of enum cases.
Further reading available:
To implement the third requirement from the list above, all we need to do is add a network
property to the FeedProviding
protocol. The following code snippet shows all code we need to satisfy the requirements I listed:
protocol FeedProviding {
var network: Networking { get }
func getFeed(_ completion: @escaping (Result<Feed, Error>) -> Void)
}
enum Endpoint {
case feed
}
protocol Networking {
func fetch<T: Decodable>(_ endpoint: Endpoint, completion: @escaping (Result<T, Error>) -> Void)
}
I omitted the extension of FeedProviding
from the code snippet above because it hasn't changed since the last time you saw it. The protocols we've defined so far looks pretty good, let's take a look at a sample implementation of fetch(_:completion)
so we can see if there's any more work to be done:
extension Networking {
func fetch<T: Decodable>(_ endpoint: Endpoint, completion: @escaping (Result<T, Error>) -> Void) {
let urlRequest = endpoint.urlRequest
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
do {
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
preconditionFailure("No error was received but we also don't have data...")
}
let decodedObject = try JSONDecoder().decode(T.self, from: data)
completion(.success(decodedObject))
} catch {
completion(.failure(error))
}
}.resume()
}
}
The first thing to notice here is that we ask the endpoint
object for a URLRequest
object. This is a good idea because by doing that, the endpoint can configure the request. For example, the endpoint is now free to decide whether any data or query parameters should be added to request and whether it should be a POST request rather than a GET request.
While it's a good idea, it seems to be an awful lot of implicit responsibility for a simple enum. We'll refactor the code in a minute so that the URLRequest
configuration is abstracted behind a protocol and we don't rely on the enum anymore in the networking layer.
Other than that I'm pretty happy with the state of fetch(_:completion:)
. Because we made the decoding generic, we're free to decode the response from our requests into any Decodable
object, and once we hide the request configuration behind a RequestProviding
protocol we're free to configure requests however we please. Let's make some final modifications to the code. First, we'll add the RequestProviding
protocol:
protocol RequestProviding {
var urlRequest: URLRequest { get }
}
extension Endpoint: RequestProviding {
var urlRequest: URLRequest {
switch self {
case .feed:
guard let url = URL(string: "https://mydomain.com/feed") else {
preconditionFailure("Invalid URL used to create URL instance")
}
return URLRequest(url: url)
}
}
}
By conforming the Endpoint
enum to RequestProviding
we still have the ability to define endpoints in terms of an enum, but we're free to configure our requests and endpoints however we please. Let's also update the Networking
protocol and extension:
protocol Networking {
func execute<T: Decodable>(_ requestProvider: RequestProviding, completion: @escaping (Result<T, Error>) -> Void)
}
extension Networking {
func execute<T: Decodable>(_ requestProvider: RequestProviding, completion: @escaping (Result<T, Error>) -> Void) {
let urlRequest = requestProvider.urlRequest
// no changes here
}
}
Note that I have renamed the fetch(_:completion:)
method to execute(_:completion:)
. The reason for this is that we don't know whether the network call that's made is going to be a GET or POST. And since fetch
implies a GET request I wanted to make sure the naming isn't ambiguous.
All that's left now is to update the getFeed(_:)
method's implementation that we added to FeedProviding
earlier:
extension FeedProviding {
func getFeed(_ completion: @escaping (Result<Feed, Error>) -> Void) {
network.execute(Endpoint.feed, completion: completion)
}
}
This method now calls execute
instead of fetch
and we need to refer to the Endpoint
enum explicitly since the type of the first argument that execute(_:completion:)
expects is now RequestProviding
instead of Endpoint
.
And that's it! With a relatively small amount of code, we were able to build a simple yet flexible and robust networking layer that can be extended, modified and updated as your project and networking needs grow in the future.
In summary
In this article, I showed you how you can take an idea or feature, and design a solid API for it with protocols. We started off by writing the code that's closed to the edge of the feature; the view model. And from there, we worked our way deeper into the rabbit hole all the way down to making the URLRequest
to an actual server. Along the way, we made some changes to our code to improve the overall design of the networking layer.
I personally think working from the outside in is a great way to make sure all of your APIs are easy to use, and it kind of forces you to write the code in a way where form follows function, and not the other way around.
If you're interested in learning more about protocols, networking and designing APIs I have a list of posts that I have published before that you might enjoy:
- Cleaning up your dependencies with protocols
- Faking network responses in tests
- Uploading images and forms to a server using URLSession
- Building flexible components with generics and protocols
Each of the posts above uses protocols and/or networking to build features that are highly testable and as flexible as possible. I definitely recommend that you read all four posts because they provide valuable insights into writing good code.
If you have any questions or feedback for me, don't hesitate to reach out on Twitter.