At Globality, we’ve been using GraphQL to write APIs for a few years. As with any adoption of a new technology, our implementation went through several revisions as we learned the nuances of real-world usage. After several months of stability, it feels like we now have an approach we can endorse.

What Problem Are We Trying To Solve?

Technologies and technical architectures are not one-size-fits-all. To understand the approach we took it helps to understand where GraphQL fits into our product.

Loosely speaking, we serve our customers through a collection of web applications, each serving a different kind of consumer. Some of these provide an experience for our end-users, some for our staff of experts, some for our engineering operations, etc. These experiences depend on our backend for state management, which we chose to implement using CRUD-oriented microservices.

A technology like GraphQL aims to reduce the encumbrance of integrating user-facing frontends with state-oriented backends. In our case, we implement dedicated API Gateway service layer that sits between each frontend application and the totality of our backend microservices.

API Gateway

This sort of API Gateway services addresses several kinds of overhead:

  1. They enable frontend developers to write APIs and to do so first, before writing user experience code. We find that an “API first” approach decreases the risk of incompatible assumptions made between frontend and backend implementations and that empowering frontend developers avoids delays caused by waiting for another developer to write the API.

    Our frontends are, unsurprisingly, written in JavaScript so we wished to find a solution that worked well in JavaScript (specifically: Node.js) independently from whatever technology choices we made on the backend.

  2. They allow for independent represention of API resources. The notion of a Bounded Context explains this best: the model used by our API resources should reflect the concepts used by the frontend itself; this model should not automatically regurgitate the choices made for managing state in the backend.

  3. They enable a consistent approach across multiple frontend applications. We have enough (10+) of these applications and few enough developers that everyone will eventually work on an API they haven’t seen before. Through thoughtful application of framework code and strong conventions, we ensure that all of our API Gateway code bases are similar.

  4. They improve performance by aggregating requests and enabling caching.

    (Aggregation and caching are rich topics; expect these to be elaborated in future posts.)

Separating Concerns

One of the keys to having Bounded Contexts is separating concerns between the resources that the frontend consumes and the backend uses. We achieve this separation with two rules:

  1. Organize code in different hierarchies based on logical function.
  2. Use a layer of indirection (e.g. dependency injection / inversion of control) to link logical layers.

We’ve ended up the following separation of concerns:

  • Routes define the API endpoints available in the gateway. With GraphQL, there is usually a single such endpoint (/graphql), though we will typically also have an endpoint for a health check, and for the GraphiQL browser (in development configurations).

  • Resources make up the schema served by the GraphQL route. These are the nouns of the Bounded Context employed by the user experience.

  • Resolvers define the business logic that connects resources into a graph. These are the core of of any GraphQL API Gateway.

  • Services and Clients define functionality used by resolvers to interact with other systems. In our platform, backend services use OpenAPI (aka Swagger) to publish their HTTP endpoints, allowing us to generate client code. Services, on the other hand, tend to be transparent wrappers around clients that introduce additional behaviors. More on this below.

For dependency injection, we use a thin wrapper around bottlejs.

As an example, here’s how a resource might interface with a resolver:

export const UserQuery = {
    user: {
        type: UserType,
        args: {
            id: {
                type: GraphQLID,
            },
        },
        resolve: getResolver('user.retrieve'),
    },
};

Opinionated Resolvers

One of the impressive things about GraphQL is that resolvers can be just about any function. In practice, however, we’ve found that its much better to have a strong opinion about what a resolver should look like and enforce a consistent approach for all non-trivial resolvers.

Our guidelines are to:

  • Separate the asynchronous fetching or mutating of data via services from the synchronous transformation of the resulting data into the expected “shape” of a resource.

  • Allow for asynchronous pre-processing of each resolver request for authorization, rate-limiting, and other systematic needs.

  • Automatically inject logs, metrics, and other telemetry into every non-trivial resolver.

Here’s how this might look:

async function aggregate(obj, args, req) {
    // delegate to a user service
    const { userService } = getServices();
    return userService.user.retrieve(req, {
        userId: obj.userId,
    });
}

function transform({ firstName, id, lastName }) {
    // shape the backend service's data into a single nam value
    return {
      id: id,
      name: `${firstName} ${lastName}`;
    }
}

const resolver = createResolver({
    aggregate,
    authorize: someAuthorizationFunction,
    transform,
});

Transparent Service Resolvers

By default, the services used by our resolvers will pass through directly to the (generated) client code for our backend services. We also enable several additional behaviors in our service configurations, all of which are meant to be transparent to our resolvers:

  • We allow caching of client responses, currently using memcached.
  • We allow deduplication and batching of client requests using DataLoader

This last item is particularly powerful. It is fairly common for a GraphQL request to refer to the same resource along multiple paths. Out of the box, DataLoader can detect these duplicate requests and reduce them to a single request to a backend services. But even better, with a little bit of care and cooperation from backend services, DataLoader can be used to transform several different requests to a single backend service via batching.

A common scenario here is the so-called “N+1” problem in which a GraphQL request asks for a list of N resources along with at least one sub-resource. A CRUD-oriented backend can typically resolve the list of N resources using one request:

GET /api/resource

Unfortunately, the resulting sub-resources will typically be requested one-at-a-time:

GET /api/sub-resource/id1
GET /api/sub-resource/id2
GET /api/sub-resource/id3

With batching, this query can be transparently re-mapped to:

GET /api/sub-resource?ids=id1,id2,id3

Performance Considerations

If you do nothing else, pay attention to caching of CORS requests. GraphQL requests tend to be POST requests; if you host your API on a different (sub-)domain from your frontend application, the browser will issue pre-flight CORS OPTIONS requests for every GraphQL query. (A typical REST API has many more GET requests which do not require pre-flighting.) A failure to enable caching of CORS requests will approximately double the number of HTTP requests required by your client.

Beyond that, we enourage a “measure twice, cut once” approach to GraphQL performance. There are many effective strategies – including service caching and batching – but problems are surpisingly often not where you first look!

Try It Out

Globality makes an effort to open-source our framework code. We use a great deal of open-source ourselves and aim to share what we find works back to anyone who finds it useful.

Much of the content in this post was adapted from conventions found in some of these projects.

Feel free to give them a try and send us feedback: