Skip to content

Designing a GraphQL server

Designing a GraphQL server

I like working on software projects that are organized intuitively. Thoughtfully arranged modules and meaningful names make writing quality code feel natural.

In my experience, Apache HTTP servers are organized intuitively ↗. File-systems and websites both follow a tree structure, so mapping the project design to the webserver’s routing rules seems like a no-brainer. Next.js’ router ↗ reflects this: the routing rules in a Next.js project will follow the file structure within the pages directory by default.

GraphQL projects aren’t as easy to organize as Apache website projects because graphs aren’t trees.

Representing a graph with files is hard

In a graph, every node must be able to connect directly to every other node; no nesting. GraphQL types don’t naturally lend themselves to modularization because, in the graph, they all exist on the global scope. This makes organizing a GraphQL server project hard, especially at scale.

Common-but-maybe-not-ideal patterns:

1. Monolith

schema/
  index.ts

Put all of your resolvers and typeDefs in one file. 10/10 cohesion, 0/10 scalability. Live fast, die young.

2. Put typeDefs with typeDefs and resolvers with resolvers

schema/
  resolvers/
    character.ts
    planet.ts
    species.ts
  typeDefs/
    character.graphql
    planet.graphql
    species.graphql

You’ll see many projects like this. It seems like a nice idea until you start adding or editing things.

You’ll edit your resolvers and break your server before realizing you forgot to edit the typeDefs to reflect the new resolvers. Once you’ve found the right type to edit, you’ll remember you actually need to write a new type to represent some input or output. You slap the new types wherever they belong in your typeDefs directory and the server works again. You might survive like this for awhile, but you’ll eventually learn a painful lesson about coupling and cohesion: resolvers and typeDefs are highly coupled and, when separated like this, become difficult to maintain at scale.

3. Group everything by domain

schema/
  character/
    ... ?
  planet/
    ... ?
  species/
    ... ?

This is a useful pattern, but it’s not really a GraphQL project design because it doesn’t dictate how we organize our resolvers and typeDefs. You finish creating directories for each domain in your project and… now what? We’re back to deciding whether to write a monolith inside each domain directory or separating our resolvers and typeDefs.

4. Proto-modules

schema/
  character/
    index.ts
    resolvers.ts
    typeDefs.graphql
  planet/
    index.ts
    resolvers.ts
    typeDefs.graphql
  species/
    index.ts
    resolvers.ts
    typeDefs.graphql

Now we’re cooking with gas.

This works nicely with the domain-based pattern to give us a fairly modular design. Even though resolvers and typeDefs are still in separate files, at least they’re grouped together. This should be adequate for most projects.

But what happens when someone decides that graphql/character/resolvers needs to be split up by type or by field? Maybe Query resolvers need to go in their own folder, not to mention Mutation and Character. Then maybe we need to do this in the planet and species directories as well. It’s easy to see how our project structure could get awkward when we start trying to modularize further.

What we really need is a set of guidelines to give us clear answers about where each piece of code belongs.

A more intuitive design

I recently reorganized a large GraphQL server and came up with a design I think is both intuitive and scalable. Let’s call it “schema-based”. I’m sure I didn’t invent this pattern, but I haven’t seen it anywhere else yet.

The idea behind this “schema-based” design is that, rather than organizing the GraphQL server based on domains, we should organize the file structure itself to reflect the GraphQL schema while still introducing nesting where possible. The sole, all-important deliverable for our GraphQL server code is the schema, so it’s sensible to design the code according to this goal.

Link to example project ↗

3 guidelines

  1. The server should be modularized according to schema types and fields.
    • The file structure will follow the schema structure. This ensures that every piece of code can be located intuitively.
  2. Each type/field is a module.
    • Each type or field should be represented by a directory or file with the same name as the type or field.
    • Every type and field should expose a default export which implements the following standard interface:
      interface GraphQLModule {
        typeDefs: DocumentNode | RecursiveArray<DocumentNode>;
        resolvers?: Resolvers | RecursiveArray<Resolvers>;
      }
      
  3. Nesting scope >= usage scope.
    • A type or field can be nested within a directory representing another type or field as long as the nested type or field isn’t used outside of that nesting directory.

Note: RecursiveArray just means an array of any dimension… we’ll flatten and merge everything later.
Note: If inheritResolversFromInterfaces is enabled, only custom fields need their own resolvers and typeDefs.

Practically, these guidelines translate into a file structure that looks something like this:

schema/
  index.ts
  Query/
    index.ts
    getCharacter.ts
  Character/
    index.ts
    isLukeSkywalker.ts
  Planet/
    index.ts
    Surface/
      index.ts
      mainFeature.ts

(Link to code ↗)

This file structure roughly resembles the actual schema:

type Query {
  getCharacter(id: String!): Character
}

type Character {
  isLukeSkywalker: Boolean!
}

type Planet {
  surface: Surface!
}

type Surface {
  features: [String!]!
  mainFeature: String!
}

Guideline #1: The server should be modularized according to GraphQL types and fields

This guideline dictates that the server structure should follow the schema structure such that, in order to discover how the schema is shaped, one need only look at the file structure in the GraphQL server codebase.

Because the file structure follows the structure of the schema, the answer to the question “where does this code belong in the GraphQL server codebase?” is simply “wherever it belongs in the GraphQL schema”.

Practically, this means that every type and field in the schema should have a file devoted to it. This leads into the next guideline:

Guideline #2: Each type/field is a module

Every type and field within the GraphQL server is represented by the GraphQLModule interface. This means that every file and directory in the schema codebase should default-export a GraphQLModule object which encapsulates all types and fields contained in that file or directory.

Consider schema/Query/getCharacter.ts:

import { gql } from 'apollo-server';

const typeDefs = gql`
  type Query {
    getCharacter(id: String!): Character
  }
`;

const resolvers = {
  Query: {
    getCharacter: (parent, args, context) => {
      return context.dataSources.characterCollection.find({
        id: args.id,
      });
    },
  },
};

export default { typeDefs, resolvers };

This GraphQLModule is then imported in schema/Query/index.ts and re-exported as part of the Query GraphQLModule:

import { gql } from 'apollo-server';

import getCharacter from './getCharacter.ts';
import getPlanet from './getPlanet.ts';

export default {
  typeDefs: [
    getCharacter.typeDefs,
    getPlanet.typeDefs,
  ],
  resolvers: [
    getCharacter.resolvers,
    getPlanet.resolvers,
  ],
};

This way, typeDefs are defined alongside the resolvers they describe. This maintains cohesion among these highly coupled aspects, increasing the clarity of the codebase. It also means that individual modules can be easily isolated for testing purposes.

One concern you might have is that it’s not obvious what the ultimate shape of Query will be in the schema, since the typeDefs are split between multiple files. However, this shape is documented in the file structure. In my experience, working this way feels rather intuitive… much like working on an Apache website.

Guideline #3: Nesting scope >= usage scope

When organizing modular code, it’s common to group files into directories in order to encapsulate related concerns. However, because graphs aren’t trees, nesting modular code in the GraphQL server codebase runs the risk of encapsulating concerns which actually aren’t limited to the enclosing concept.

Guideline #3 supports an intuitive codebase by ensuring that code can be nested, but that it will never be nested somewhere it doesn’t really belong.

Building the schema

So, we have our server files organized according to type and field, all exporting a GraphQLModule containing arrays of resolvers and typeDefs. Before we build our schema, our top-level type modules need to be flattened and their resolvers merged. To accomplish this, we need some helper code to flatten the typeDefs and resolvers arrays, merge the resolvers into a single object, and then feed the results to makeExecutableSchema (or equivalent), which will generate a GraphQLSchema object from our collection of typeDefs and resolvers.

The helper code is a bit lengthy to include inline here, but you can view a working example on Github ↗.

Using the makeSchema function from the helper code linked above, schema/index.ts looks something like this:

import Character from './Character';
import Planet from './Planet';
import Query from './Query'; 
import Species from './Species';
import makeSchema from './helpers/schema';

export default makeSchema([
  Query,
  Character,
  Planet,
  Species,
]);

These patterns can be used to apply custom scalars ↗ and directives ↗ to the schema as well.

Domain-based modules and federation

Let’s say you work on a large GraphQL server with hundreds of top-level types and you need to organize your server by domain. You can still use this pattern by exporting a GraphQLModule from each domain directory and then generate your schema by feeding all of your domain modules to makeSchema.

schema/
  starWars/
    index.ts
    Query/
      index.ts
      getLivingMasters.ts
    Mutation/
      index.ts
      playCantinaBand.ts
    Planet/
      index.ts
      isBlownUp.ts
  twilight/
    index.ts
    Query/
      index.ts
      getBabyNames.ts
    Character/
      index.ts
      isJacob.ts

This works nicely with federation too, since you can export whatever you need from each federated server (GraphQLModule objects or individual schemas) and compose your supergraph.

Related stuff

If you like what I did here but you’d rather use a third party package, look into GraphQL Modules ↗. They even provide an API for dependency injection if you find that useful in your server.

Also be sure to look into GraphQL Code Generator ↗ or another codegen solution for generating Typescript types from your GraphQL schema. A solution like this will help ensure your GraphQL server is type safe from front to back.