Keeping Your Network Layer Clean With DRY

Nearly every app interacts with a server on the web, and often more than one. You’d think that we all take great care of our network layer and make sure it evolves into something robust and reliable.

Hah. Not in my experience!

Despite network layers playing a critical role our apps, they often become big and difficult to maintain. In fact, they grow in ways we don’t expect resulting, in files that can be thousands of lines long.

With that in mind, let’s get back to basics and create a network layer from scratch, using the “Don’t Repeat Yourself” principle to help us along. We’ll create a networking class that interacts with SWAPI, an open API for Star Wars resources 🤓

In this article, we’ll look at:

  • How to build a network layer out of small, testable parts.
  • How we can apply the DRY principle to inform our refactoring.
  • How we can conceptualize our code in terms of domains, and how to build bridges between them.

Should You Use a Networking Library? No.

When building a new network layer from scratch, the first question that often pops into our minds is if we should use a 3rd party library or not.

You’ll notice that in this article, we use Apple’s URLSession. Not Alamofire. Not RestKit.

In the vast majority of cases, you don’t need a separate networking library.

Apple’s URLSession is so robust and full-featured that the overhead in maintaining a separate 3rd party library is simply not worth it. I’m sure there are situations where it can be really useful, but it’s been years since I’ve encountered one.

Calling an API Using URLSession.

Alright, let’s create our first request!

SWAPI exposes an endpoint that returns a list of Star Wars characters. The URL for this endpoint is https://swapi.co/api/people/ and returns a JSON array of characters like Luke Skywalker, C-3P0, etc. Let’s hop right in and see what that looks like.

class SWAPI {
    func getPeople(completion: @escaping (Result<Data, Error>) -> Void) {
        guard let peopleURL = URL(string: "https://swapi.co/api/people") else { fatalError() }
        let peopleURLRequest = URLRequest(url: peopleURL)

        let dataTask = URLSession.shared.dataTask(with: peopleURLRequest) { (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                completion(.success(data))
            }
        }

        dataTask.resume()
    }
}

For many of us, this code is second nature.

(And if it’s not the case for you yet, don’t worry. It will be soon 😉)

Let’s break down what’s going on here:

  • We’re defining a method called getPeople that takes a completion handler
  • On line 2, we prepare the URL
  • On the line right after, we prepare a URLRequest using that URL.
  • Then, we prepare a dataTask to fetch the JSON data at that URL.
  • Finally, we start the data task by calling resume().

For now, we’ve put everything in the same method. We’ll leave it there for now, and if we follow DRY, we should see opportunities to clean things up as we move along.

Creating a Second Request and Cleaning Up

Alright, let’s create a second request. This time, we’ll fetch Starships from the GET Starships endpoint.

class SWAPIServerHost {
    func getPeople(completion: @escaping (Result<Data, Error>) -> Void) {
        ...
    }
    
    func getStarships(completion: @escaping (Result<Data, Error>) -> Void) {
        guard let starshipsURL = URL(string: "https://swapi.co/api/starships") else { fatalError() }
        let starshipsURLRequest = URLRequest(url: starshipsURL)
        
        let dataTask = URLSession.shared.dataTask(with: starshipsURLRequest) { (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                completion(.success(data))
            }
        }
        
        dataTask.resume()
    }
}

Our starships endpoint look a lot like our people endpoint, so let’s factor out the common part into its own method:

class SWAPIServerHost {
    func getPeople(completion: @escaping (Result<Data, Error>) -> Void) {
        guard let peopleURL = URL(string: "Derp") else { fatalError() }
        let peopleURLRequest = URLRequest(url: peopleURL)
        
        resumeDataTask(withRequest: peopleURLRequest, completion: completion)
    }
    
    func getStarships(completion: @escaping (Result<Data, Error>) -> Void) {
        guard let starshipsURL = URL(string: "Derp") else { fatalError() }
        let starshipsURLRequest = URLRequest(url: starshipsURL)
        
        resumeDataTask(withRequest: starshipsURLRequest, completion: completion)
    }
    
    private func resumeDataTask(withRequest request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
        let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                completion(.success(data))
            }
        }
        
        dataTask.resume()
    }
}

That’s already better. We’ve moved the data task creation to its own method, reducing the amount of duplication. Well done, team!

Thinking About the Bigger Picture

Both of these endpoints support a search parameter. With this parameter, we can filter the names of the entities that are returned.

We could add a search parameter to each of the methods for these requests, but that’s more duplication. Let’s rethink the problem instead. What abstraction are we missing?

To answer this, let’s think about what exactly a request is? It’s not just a URL, right? It’s also an HTTP method, headers and parameters. What’s more, different requests can have similar characteristics. One of those characteristics could be accepting a search parameter. This is the type of behaviour we want to capture.

Let’s see if we can model this in a way that helps us. Here’s a protocol called RequestTemplate.

protocol RequestTemplate {
    var method: HTTPMethod { get }
    var path: String { get }
    var parameters: [URLQueryItem] { get }
    var headers: [String: String] { get }
    var body: Data? { get }
}

RequestTemplate represents all the different interesting things a request could need. Now we can redefine our earlier endpoints in terms of RequestTemplate:

struct GetPeopleRequest: RequestTemplate, SearchableRequest {
    let method: HTTPMethod = .GET
    let path = "/api/people"
    var parameters: [URLQueryItem] = []
    var headers: [String: String] = [:]
    var body: Data? = nil
}

struct GetStarshipsRequest: RequestTemplate, SearchableRequest {
    let method: HTTPMethod = .GET
    let path = "/api/starships"
    var parameters: [URLQueryItem] = []
    var headers: [String: String] = [:]
    var body: Data? = nil
}

Wow, isn’t that beautiful? We have a concise, focused struct that represents a request. More importantly, we also have a clear way to add new requests. This will help us avoid bloat as our project grows larger.

But we still have some duplication here, don’t we? On one hand we have these request objects that represent requests that we want to perform. But there’s also URLRequest, which, essentially, represents the same thing. Does it make sense to have both? If so, how do we reconcile the two?

Building Domains and Bridges

I think it’s interesting to think about problems like these in terms of domains. On one side, we have this little cluster of classes we’re building in order to operate on the Star Wars API. Maybe this is something we could pull out into its own module, and open source it.

On the other side, we have this generic framework in Foundation that provides URLSession and friends that can operate on any API.

How do we bridge the two? How do we go from our domain of SWAPI, to the generic generic domain of URLSession?

In this case, we need to build a bridge. Some interesting questions to ask ourselves are:

  • What’s the common currency between these two domains?
  • What’s the result of one domain that can be handed off to the other domain?

In our example, I’d propose that the “common currency” is the notion of a request, since it exists in both worlds. Our domain has RequestTemplate, and Foundation has URLRequest. Therefore, we need to bridge one to the other. Let’s create an extension that provides just that.

extension URLRequest {
    init?(with request: RequestTemplate, baseURL: URL) {
        guard let fullURL = request.fullURL(baseURL: baseURL) else { return nil }

        self.init(url: fullURL)
        self.httpMethod = request.method.rawValue

        for (key, value) in request.headers {
            self.addValue(value, forHTTPHeaderField: key)
        }
    }
}

Now that we’ve rethought this problem, we can go ahead and get back to search. Let’s define a protocol which encapsulates exactly what we want to add.

protocol SearchableRequest {
    mutating func addSearchParameter(_ searchTerm: String)
}

If we were to implement this on our requests, the implementation would look the same. Let’s take a shortcut here and define a protocol extension instead. This will allow us to capture the semantics of what it means to be a searchable request.

extension SearchableRequest where Self: RequestTemplate {
    mutating func addSearchParameter(_ searchTerm: String) {
        appendParameter(name: "search", value: searchTerm)
    }
}

And now, to add this functionality to GetPeopleRequest and GetStarshipsRequest, we simply need to extend them.

extension GetPeopleRequest: SearchableRequest { }

extension GetStarshipsRequest: SearchableRequest { }

Our grand finale

Now that we’ve implemented search, how do our method signatures change? Does getPeople and getStarships still make sense?

The answer, like many things in software development, is “it depends”. Here are a few ways to look at the problem.

If your goal is to encapsulate as much information as possible inside your SWAPI class, then I suggest to continue as things are now. Your new method signatures would look a bit like this:

class SWAPI {
    func getPeople(search: String? = nil, 
                   completion: @escaping (Result<Data, Error>) -> Void) { ... }

    func getStarships(search: String? = nil, 
                      completion: @escaping (Result<Data, Error>) -> Void) { ... }
}

However, if you want maximum flexibility, you might want to pull your API boundary away from the user. Concretely, this means having a method that accepts any RequestTemplate. In doing this, you simplify the code inside SWAPI, at the cost of pushing more of the burden onto the users of this class. You can imagine the new function signature looking a bit like this below:

class SWAPI {
    func executeRequest(_ request: RequestTemplate, 
                        completion: @escaping (Result<Data, Error>) -> Void) { ... }
}

Which of these options is best? It’s hard to say! I’d prefer the second approach in most apps. However, if I was creating an open source component, I’d opt for the simplicity of the first.

Wrapping Up

In this article, we saw how we could apply the DRY principle to create some meaningful feedback about when we needed to refactor our code. We saw how to decompose an API layer into smaller parts, and we saw how we can recombine those parts to create a layer that’s easy to use.

I’ve prepared a little playground in which you can you see all of the steps mentioned in this article. Join the newsletter and it’ll be sent right to ya!