Scalable Networking in Swift

With a sprinkling of Combine

Since the advent of Codables, we’ve been edging closer to a completely typed networking solution built into Foundation. Whilst our models are now safe, our requests themselves typically still rely on strings and very manual parameters, leaving us open to developer error.

Even the best of us make mistakes, let’s be honest.

A considerable amount of time could be spent debugging these errors in a large project, which could be better spent on new features. To lower the risk of this we’ll need to turn our route data into something typed.

The concept I’ve always worked with is that a router performs a Route, and returns a result to the service, or view, that wants it. The following will be based around that.

First, I needed to set some basic goals. These were:

  • The only concern of a router should be taking a Route and performing it via URLSession — We won’t be using any pods here.
  • A Router should be able to take any Route passed to it, and perform without any extra logic.
  • A Route should contain any information the Router may need to do that.
  • Transforming into a Codable should be automatic and seamless.
  • It must be easy to add a new Route.
  • Finally, the code that calls the Route has to be clean, easy to read, and make sense to new developers on a project using this approach. We don’t want huge onboarding times.

Designing the Route Protocol

API Routes are typically performed using a few simple bits of data. The path, query, and body. The part of the app that wants the data doesn’t care about this detail, it just knows the result that it wants.

The body, both in response and request, can be expressed using a Codable. Sometimes, these are expected to be empty, so we’ll need a placeholder to handle that.

The method, path, and auth requirement are static variables, as these will be defined once and not change for each instance of the route.

As for the instance variables, we only need an optional requestObject with the path and query parameters. The requestObject is optional as GET requests won’t have them, so we allow for this to be completely empty. We don’t have a responseObject here, as we want the Router to return that to us.

Empty is an empty Codable that entirely serves as a placeholder, to tell the router not to do anything with that object, to handle any instance where we don’t want to serialize.

With this information, we have everything we need to create our request.

Building the Request

toRequest is the magic that allows the Router code to be very minimal. It handles all logic that turns a route into an instance of a URLRequest. We can define a basic implementation of this as an extension of Route.

The toRequest method is adding our path parameters, query items, and serializing the body into JSON if needed.

You’ll notice that queryParameters and pathParameters have some default implementations in this extension. This is just to keep routes minimal where we don’t need these parameters.

Some routes may need to alter this, such as ones that have media in their bodies instead of Codables, but that simply means overriding this method and adding a specific bit of body code.

Now we’ve seen how it works, we can look at an example Route that gets a specific to-do object from the web.

Let’s go through this example and look at what we have.

  • We don’t have a request body, so we’ve set that to Empty.
  • The response is a single to-do, so we set that as ResponseType.
  • Our method and path are set as static variables, as they won’t change.
  • The default requestObject expects an optional copy of RequestObject, in this case empty, so we just have an optional Empty.
  • We know our query parameters are always empty, so we just create an empty array.
  • The only path parameter we have specifies the specific to-do we’re looking for, so we use a local variable of Todo and get the ID from that.

Performing a Route

With all the details abstracted away, and our routes ready for construction, we need to actually perform them.

After constructing an instance of GetTodoRoute, we just pass it to the router.

That’s it.

You might expect that the Router code is a bit of a mess but because of the toRequest method, it’s actually very readable.

The Router handles decoding and mapping any potential errors, before returning a Publisher. If the response type is empty, we have a different performRequest method that maps errors.

The final point in the chain is the object that actually wants the Todo. That simply asks the TodoService to get a to-do, and waits for a response.

There are still a few things I’ve not considered, such as adding authentication retrying, but this will serve as a good, extensible setup.

Having my networking like this in my current project has led to faster implementation of new routes, leaving more time to sit and debug SwiftUI.
The time saved after initial setup only grows the more your codebase does.

Thanks for reading!