GraphQL on iOS: How We Decided Against Apollo & Where We Went Next

Principal iOS engineer Joel Kin shares more about Handshake's choice in our API development using GraphQL on iOS and how it best serves our users.

Why GraphQL?

At Handshake, we’ve long wanted a unified API for our web and mobile clients. This would eliminate a long-standing source of subtle (and not so subtle) bugs that arise as our backend engineers constantly attempt to reconcile two different APIs with slightly different behaviors. GraphQL is designed for this situation: although the web and mobile clients have different needs, they’d be able to meet those needs from the same endpoint. Less backend maintenance and client-side bug fixing means more time for new feature development, which ultimately means more students getting their first job. Let’s go!

Apollo versus GraphQL

Of course, nothing’s ever that easy. If you start your integration journey by Googling for “GraphQL iOS”,  you’ll find a lot of resources about using the Apollo client library, and very little else. (This blog post is, in part, an attempt to balance the ratio.) That’s not going to work for every app: Apollo is an opinionated library, and opinions may differ!

Apollo’s opinions

Fundamentally, Apollo believes that models should be small and query-specific. They should represent the result of a specific query, meant to populate a specific view or inform a specific business logic flow. This means a proliferation of single-use types with mainly non-optional fields, many of which types can be seen as facets onto a single underlying model acting as the source of truth. The fact that said source of truth lives in the backend, behind the GraphQL API, is why Apollo’s query management is so important. The fact that many model type facets will exist in the client is why Apollo’s type generation is so important.

Handshake’s data model philosophy

There’s nothing inherently wrong with Apollo’s data model. However, for the past couple of years, the Handshake iOS team has been moving in a different direction. We attempt to maintain a client-side cache of the full backend data model insofar as it relates to the app. Just like Apollo, we want to make sure our models’ fields are mostly non-optional. Therefore our endpoint responses tend to be fairly verbose. 

If we’re retrieving an instance of a model, we’ll want to get all the required data on it in order to maintain the invariants we share with the backend. This makes our code safer: knowing that the user’s school year is a required property, and treating it as required in client code as well, means we can eliminate branching and type checking. We also, more often than not, use all that data somewhere in a given user flow. For instance, rather than requesting one subset of a Job object for our job listing screen and another subset when the user enters the job application flow, we might get all that data at once – saving a network call at the cost of a larger initial payload.

So Apollo is wrong?

This is a philosophical difference about how client-side data modeling should work. There’s nothing to say that either choice is more correct, but the fact of the difference means we’d have some serious work to do in order to adapt our app to Apollo. It would certainly be possible to lightly misuse the library to maintain the shape of data that we’ve prioritized, but not without giving up some of the benefits.

Pre-existing infrastructure

If that were the only obstacle, it still might be worthwhile. We could change our data model, or make somewhat non-optimal use of Apollo. But there’s more. Apollo is very much an all-in-one library. It handles not only generating and performing GraphQL queries and the underlying networking, but also model generation, caching and invalidation, JSON parsing, and unidirectional data flow. All good things, and mostly necessary. Of course, as a mature codebase, we’ve already got solutions for the necessary things. At a certain point, we had to ask ourselves how much of the existing codebase we were willing to restructure in order to gain the benefits Apollo offers. 

(One last consideration about delegating all these responsibilities to a library: we’ve been working hard to reduce the number of third-party dependencies in our codebase. There are plenty of arguments around this philosophy, and this blog post isn’t meant to rehash them; in short, adding new fundamental dependencies is a decision we take seriously and we prefer not to do it.)

Our solution

Given all this, we decided to go in a different direction. We’ve built a very simple GraphQL API abstraction that uses our existing networking stack to perform the underlying requests. We’re writing a fairly small number of queries by hand (or with the help of tools like Paw, Insomnia, and GraphiQL) to match our old REST endpoints; and we’re requesting relatively heavy payloads, with which we populate our existing data model. In short, we’re getting the benefits of sharing the API with the web, but we’re not completely upending the networking and model layers that we’re already accustomed to. We’re absolutely missing out on a lot of benefits Apollo would give us, but in terms of being able to slot GraphQL into our existing codebase, this is probably the fastest and lowest friction option available to us.

Code

What does this look like? There are several pieces that come together: a protocol that tells us what our GraphQL query types need; concrete types that implement that protocol; networking code that knows how to use these queries; and the underlying query strings that are actually sent to the GraphQL endpoint. Sounds like a lot, but all these pieces are surprisingly small. Let’s look at the code Handshake uses to fetch employers we want to recommend to a student.

Protocol

First, we create a Query protocol to which all our queries will conform. It has an associatedtype for the data we expect; the body of the actual GraphQL request we’ll send; and a decoding function to turn a network response into our data types.

protocol Query {
/// The format of the response to expect from the GraphQL request
associatedtype Response: Decodable
/// The full string to send in the GraphQL request
var body: String { get }
/**
Decode a `Data` object from the GraphQL endpoint into our expected `Response` type.
– Parameter data: `Data` – bytes from the network
*/
static func decodeResponse(_ data: Data) throws -> Response
}

Query has an extension providing a default implementation that works for most cases:

extension Query {
static func decodeResponse(_ data: Data) throws -> Response {
JSONDecoder().decode(Response.self, from: data)
}
// The query file on disk
var body: String {
// Assume it has the same name as the query type
try! String(contentsOfFile: Bundle.main.path(forResource: String("\(type(of: self))".split(separator: ".").last!), ofType: "query")!)
}
}

Concrete type

Because of that default implementation, a concrete type implementing Query can be quite simple, only needing to define the actual response type we expect from GraphQL:

/// A GraphQL query to fetch the recommended employers for this user
struct RecommendedEmployers: Query {
struct Response: Decodable {
let recommendedEmployers: [Employer]
}
}

Networking

Our HTTP client (using Alamofire under the hood, for now) needs to know how to send our concrete GraphQL query types:

class HTTPClient {
let manager = Alamofire.SessionManager()
/// Issue a GraphQL request based on the given `Query`
func request<T: Query>(query: T, completion: @escaping (Result<T.Response, Error>) -> ()) {
// all GraphQL requests go to the same endpoint
let url = URL(string: "\(baseUrl)/graphql")!
// under the hood, a GraphQL request is just a `POST` with a funky `body`
manager.request(url, method: .post, parameters: query.body).responseJSON {
response in
switch response.result {
case .success: completion(.success(try! T.decodeResponse(response.data)))
case .failure(let error): completion(.failure(error))
}
}
}
}

Query string

The actual string we send as the Query’s body – RecommendedEmployers.query, in this case – is a plain old GraphQL query, pulled from our endpoint’s introspection capabilities:

query {
recommendedEmployers() {
id
name
follow {
id
}
industry {
id
name
}
location {
id
city
state
}
institutionSize
logoUrl
website
}
}
view raw 1801584810_5 hosted with ❤ by GitHub

And for this only slightly simplified example, that’s all there is to it!

Conclusion

Ultimately, our decision is mostly a result of all of the other decisions we’ve made in the course of this app’s multi-year development process. We’ve got things working, and we now prioritize continuity over revolution. It’s a conservative mindset, but then, once you reach a certain scale, engineering is often a conservative discipline. (I’m not talking about politics, please don’t @ me.) If we were starting a new app from scratch, Apollo would be a lot more attractive. So when Handshake pivots to on-demand ice cream delivery, keep an eye out for a followup post. And if you want to work on that ice cream delivery app yourself, check out our available jobs.

Photo by Porapak Apichodilok from Pexels