Modular Networking In Swift
Networking has always been a complex problem in mobile development. You want your application to be fast, have up to date information, and be maintainable. Over the years as server technology has gotten more complex, so have our requirements for mobile networking. A simple URLSession abstraction no longer scales to meet modern business needs.
These days it is common to create a networking abstraction that accepts a generic request object with return type information, and then returns either a typed object or an error. But how do you handle all of the other baggage that is involved in making a network call? Some common problems include adding required headers, token refresh, request accumulation, caching, etc. At Ibotta, we’ve implemented a lightweight middleware and postware system to maximize reusability and testability.
The idea is to create a series of small utility classes that mutate requests in a chain to enable business logic. We call these small classes middlewares and postwares. Middlewares run before a request hits the network and can either pass the request along to the next middleware, or abort the operation. Postwares run after a request hits the network.
Implementation
I am going to go over the high level implementation, but in order to stay focused on the meat of modular networking, some boiler plate code will be excluded. However, it should be pretty clear how to connect the missing dots to use this in your own app.
We are going to create two key classes to handle our logic: an HttpManager
and an HttpClient
. The manager will be responsible for processing a request through the middlewares, and later processing the response through the postwares. The client will be in charge of sending the request to the network. Their public interfaces look like:
An HttpRequest
contains all of the information needed to send a request to the network. But it does not contain type information about the expected return type; towards the end of this article we will add that capability on top of the HttpManager
.
Next we need to define what middlewares and postwares look like. They will end up housing most of our networking logic. For convenience, we sometimes call middleware and postwares HttpWare
. Their protocols look like:
Notice how a middleware can abort a request. This can be used for things like early exiting when a cache hit occurs, or aborting if we know the device is offline. To help keep HttpWares
in an ordered chain, we allow them to specify a sort order.
If we wanted to add authentication headers to all requests we could create a super simple middleware like:
Assuming we can mock the TokenService
, this tiny class is super easy to test. One of the big benefits to this approach is testability. We can build out complex HttpWares
and write pretty simple unit tests directly against them. Then all of these well-tested classes can be injected into our HttpManager
, and we can be confident everything works.
Middlewares and postwares can act as a bridge between services and our networking requests. In the above example, the AuthMiddleWare
acts as a bridge between the TokenService
and our networking layer.
So now that we’ve added an auth token to all our requests, what happens if we need to refresh the auth token? We could have several incoming requests when we notice we need to refresh our token, so we will want to collect all incoming requests while we refresh our token. This can be done with another middleware:
Notice how this middleware defines an order of .middle
and the AuthMiddlWare
defined an order of .last
. Ordering a lot of HttpWares can be tricky, but we’ve found that in practice a lot of wares don’t require a predefined order.
Now let’s say we made a network call and we received a 401 letting the app know to log the user out. This is a great opportunity to use a postware:
We’ve just enabled complex auth token logic without touching our core networking code. Pretty cool!
We’ve only looked at classes that are either a middleware or a postware, but a class could be both a middleware and postware. A 304 cache for instance would want to save response data as a postware and adjust requests with a timestamp as a middleware.
To implement the facilitation of these HttpWares, we define a function to process a request through the middlewares, and a function to process a request + a response through the postwares. This would look something like:
Polishing Up Our Implementation
At this point our HttpManager
accepts an HttpRequest
, but an HttpRequest
does not contain type information. To polish up our HttpManager
, we will implement a new request type that contains type information so we can return type safe results.
Then inside our HttpManager
we will convert a Request
to an HttpRequest
, and then process that request through our HttpWares.
Another optimization we can make is to define a ServiceDefinition
for each service we send networking requests to. A ServiceDefinition
contains information like base url and default encoding strategies. Then when creating an HttpManager
, a ServiceDefinition
can be injected at initialization.
Conclusion
Using middlewares and postwares to encapsulate networking logic is a great way of maintaining separation of concern while enabling complex networking logic. There is no limit to what you can build into your networking layer using this modular approach. This is a great bottom layer to build additional layers of abstraction on top of.
Some useful wares we use include request accumulators, http cache, 304 cache, time logging, token refresh, token validation, and various middlewares for different required headers. At Ibotta we have 19 middlewares and postwares that our services use. Some are used for almost every request, and some are only used in rare cases.
We’re Hiring!
If these kinds of projects and challenges sound interesting to you, Ibotta is hiring! Check out our jobs page for more information.