At Nutmeg, we know that adopting the latest technologies can help us develop and deliver a faster, higher-quality app experience for our clients. When updating the Nutmeg iOS app, we consistently look for ways to create solutions in house, using Apple’s latest APIs, removing our reliance on third parties, and helping us deliver more robust and scalable solutions.
One such case is our networking layer, with which we make all our requests and convert the data we receive into useable data models in the app. Previously, we relied on a third-party framework to make these requests with our own custom logic on top. While this was okay initially, it quickly became unscalable as the code grew, and the number and complexity of data requests we were making increased. We decided to start investigating other approaches.
We knew we wanted a simple solution, one that was easy to understand and use. There are various other third-party solutions that are used by many other apps, but ideally, we wanted a light solution over which we could have full control. We needed the flexibility to update it to our needs, without the risk of another dependency that could change or no longer be supported at a moment’s notice. Since we had recently moved our minimum supported iOS version from 12 to 13, this granted us more native solutions to choose from, as they are only supported from iOS 13 upwards. Most notable of these was Combine.
Combine is Apple’s reactive programming framework. It allows us to process streams of data over time, as well as merge those streams together and perform other complex logic on them with minimal code. This is perfect for networking, as it allows us to manipulate the data easily for hundreds of different cases, and best of all, it’s native to the platform and therefore well supported throughout Apple’s ecosystem.
How does this look in practice?
Our previous networking implementation was completion based, having separate completion blocks for each case, like success and failure, that was passed through several layers. This meant a lot of boilerplate. We also had lots of business logic within the networking code itself that should not have been there, e.g. attaching request specific headers or forcing multi-factor authentication. We could easily have over 150 lines of code just for creating a simple request and making it, not including any of the third-party code under the hood. Our new approach was more like 25 lines per request.
We also wanted better separation of concerns and increased testability, so decided to abstract as much as possible and separate the logic into distinct layers. At a high level, this looks like this:
The Request represents any information needed to construct the final request object, like the path and payload. The worker is responsible for converting this request model into a native URLRequest, as well as attaching any headers. It also holds information about the host and scheme, meaning you can simply swap workers for different hosts (e.g., production or development environments). Finally, the engine is responsible for making the request, and performing some very basic logic such as handling and mapping appropriate network errors.
- The Request
At the top of the chain, we have the request:
This protocol represents all that is required to create a request; a path and what sort of network method to use, along with other optional information. This abstracts the request information from the underlying method used to create and make it.
We also have a DecodableRequest protocol too, which allows us to automatically map any data we receive to a decodable model, using an associated type:
This type can be passed down the engine, and we can use Combine to map this type to the model automatically. This simple addition helped us remove hundreds of lines of code from our project. Less code is not always better code, but in many circumstances when using modern coding techniques like this, there tends to be less duplication of logic and the code becomes easier to understand, and easy to read code is the foundation of scalable and maintainable solutions.
- The Worker
The worker helps us convert the abstracted requests into something the engine can use. It contains the scheme and host of the URL we are planning to hit, as well as any middleware, which we will come to a little later.
Inside the worker, we take the request and map it onto URLComponents which helps us properly form the URLRequest with an optional payload. It then runs the request through the middleware in order to, for example, attach headers. The API for this looks like this:
- The Engine
The core of this implementation, the “Engine”, uses Apples native “URLSession” (https://developer.apple.com/documentation/foundation/urlsession), which exposes native Combine APIs to make data requests. We can then chain operations one after the other to convert the response into a more useful format, i.e., an error or a data model. This is how it looks:
In the code above, we make a data request, map that request to our model, apply any response middleware (for example, logging), and then map any errors received to a more useful error we can use upstream. Rather than just returning the data, we use and intermediary “Response” type, which we will come to later.
We previously saw the concept of “Middleware” in our data response, which allows us to perform logging for example. The basic concept is to allow us to intercept a request or response “mid-way” through its journey whilst keeping the logic abstracted, for example before the request is submitted or before a response is returned. As an example, we could use middleware to attach specific headers to a request before it is made. Middleware is again a protocol that looks like this:
- Response model
We also previously saw that our engine returns a Response object upstream rather than just the raw data. We also have a corresponding ModelResponse, which is identical but with an added decoded model for the DecodableRequests. The reason for this is to expose more information about the response upstream other than just the data or just the model.
As you can see above, this includes any headers sent with the request, status code, and in the ModelResponse, contains the data it was mapped from. This allows for much better logging and helps us too when debugging.
Advantages and draw backs
Technical debt like this can sometimes be hard to justify to anyone outside the team, but we are lucky to be so well supported at Nutmeg to tackle pieces of technical work like this, even though the advantage may not immediately obvious to the client.
In the example of NetKit, outside of the technical advantages of less complexity, reduced dependencies and greater flexibility and understanding of the networking code, the main relevance of this work is related to time. Requesting data had reduced complexity and requires less code, meaning we can move faster when developing new features or finding bugs.
Something should also be said for having an in-house framework. Although you run the risk of ‘reinventing the wheel’ when writing your own solutions for common problems, the great advantage is the transparency and self-reliance of something proprietary. You have no dependencies, so issues or needed functionality can be resolved within the team without relying on external parties. Not to mention there are many learning opportunities that come from tackling these problems for the team; it really helps us to grow and improve.
The main disadvantage we have had is the learning curve needed for Combine, its syntax and the different approach to solutions that is needed. Reactive programming takes time to get used to and can seem daunting the first time you see it, but we have a team that loves to learn, and the advantages after a few weeks have become clear.
Our move to NetKit is ongoing and we are still learning, but we have already started to look at where else we can use Combine to improve the app. From what’s displayed on screen, all the way through to getting the data, we are using this new framework to improve both the user and developer experience.
We are also starting to use more exciting frameworks like SwiftUI, which in combination with Combine, will help us bring the Nutmeg user experience to new heights, but more on that soon.
As with all investing, your capital is at risk. The value of your portfolio with Nutmeg can go down as well as up and you may get back less than you invest.