tutorial // May 26, 2021

How to Write and Organize a GraphQL Schema in JavaScript

How to write a GraphQL schema using a folder and file structure that makes comprehension and maintenance less overwhelming.

How to Write and Organize a GraphQL Schema in JavaScript

In an app that's using GraphQL for its data layer—meaning, the thing that your app uses to retrieve and manipulate data—the schema is the lynchpin between the client and the server.

While schemas in GraphQL do have rules about how you write them, there aren't any rules about how to organize them. In large projects, organization is the key to keeping things running smoothly.

Getting started

For this tutorial, we're going to use the CheatCode Node.js Boilerplate as a starting point. This will give us access to a functioning GraphQL server with a schema already attached. We'll modify that schema and discuss its organization to help you inform the organization of your own GraphQL schema.

First, let's clone a copy of the boilerplate from Github:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

Next, cd into the boilerplate and install its dependencies:

Terminal

cd nodejs-server-boilerplate && npm install

With the dependencies installed, now we can start up the development server:

Terminal

npm run dev

With that, we're ready to get started.

Setting up your base folder structure

In an app using GraphQL, there are two core pieces: your GraphQL schema and your GraphQL server (independent from your HTTP server). The schema is attached to the server so that when a request comes in, the server understands how to process it.

Because these two pieces work in tandem, it's best to store them alongside one another. In the example project we just cloned, these are placed in the /api/graphql directory. Here, the /api directory contains folders that describe the different types of data in our app. When combined, our schema and server represent the GraphQL API for our application (hence the location).

Inside of that folder—/api/graphql—we separate our schema and server declarations into two files: /api/graphql/schema.js and /api/graphql/server.js. Our focus moving forward will be on the schema part of this equation, but if you'd like to learn more about setting up a GraphQL server, we recommend reading this other CheatCode tutorial on setting up a GraphQL server. Before we wrap up, we'll discuss how attaching the schema we write to a GraphQL server works.

Organizing your types, query resolvers, and mutation resolvers

Next, the core part of our organizational pattern will be how we separate the different types, query resolvers, and mutation resolvers in our GraphQL API. In our example project, the suggested structure is to keep everything organized under the /api directory we learned about earlier. In that folder, each data "topic" should get its own folder. A "topic" describes a collection or table in your database, a third-party API (e.g., /api/google), or any other distinct type of data in your app.

├── /api
│   ├── /documents
│   │   ├── /graphql
│   │   │   ├── mutations.js
│   │   │   ├── queries.js
│   │   │   └── types.js

In respect to GraphQL, within a topic folder, we add a graphql folder to organize all of our GraphQL-related files for that topic. In the example structure above, our topic is documents. For this topic, in the context of GraphQL, we have some custom types (types.js), query resolvers (queries.js), and mutation resolvers (mutations.js).

/api/documents/graphql/types.js

const DocumentFields = `
  title: String
  status: DocumentStatus
  createdAt: String
  updatedAt: String
  content: String
`;

export default `
  type Document {
    _id: ID
    userId: ID
    ${DocumentFields}
  }

  enum DocumentStatus {
    draft
    published
  }

  input DocumentInput {
    ${DocumentFields}
  }
`;

In our types.js file, we export a string, defined using backtics `` so that we can take advantage of JavaScript's (as of the ES6 edition of the standard) string interpolation (allowing us to include and interpret JavaScript expressions within a string). Here, as an organizational technique, when we have a set of properties that are used across multiple types, we extract those fields into a string (defined using backticks in case we need to do any interpolation) and store them in a variable at the top of our file (here, DocumentFields).

qPnnNBfWKCsyutaR/qaYklCcSawPgztus.0
A review of how requests and data flow through a GraphQL API.

Utilizing that interpolation, then, we concatenate our DocumentFields at the spot where they're used in the types returned in the exported string. This makes it so that when our types are finally exported, the "shared" fields are added to the types we're defining (e.g., here, type Document will have all of the properties in DocumentFields defined on it).

/api/documents/graphql/queries.js

import isDocumentOwner from "../../../lib/isDocumentOwner";
import Documents from "../index";

export default {
  documents: async (parent, args, context) => {
    return Documents.find({ userId: context.user._id }).toArray();
  },
  document: async (parent, args, context) => {
    await isDocumentOwner(args.documentId, context.user._id);

    return Documents.findOne({
      _id: args.documentId,
      userId: context.user._id,
    });
  },
};

Looking at our queries.js file next, here we store all of the resolver functions for our queries related to the documents topic. To aid in organization, we group together all of our resolver functions in a single object (in JavaScript, a function defined on an object is known as a method) and export that parent object from the file. We'll see why this is important later when we import our types and resolvers into the schema.

/api/documents/graphql/mutations.js

import isDocumentOwner from "../../../lib/isDocumentOwner";
import Documents from "../index";

export default {
  documents: async (parent, args, context) => {
    return Documents.find({ userId: context.user._id }).toArray();
  },
  document: async (parent, args, context) => {
    await isDocumentOwner(args.documentId, context.user._id);

    return Documents.findOne({
      _id: args.documentId,
      userId: context.user._id,
    });
  },
};

In respect to structure, mutations.js is identical to queries.js. The only difference here is that these resolver functions are responsible for resolving mutations instead of queries. While we could group our query and mutation resolvers into a single resolvers.js file, keeping them separate makes maintenance a bit easier since there's no inherent distinction between the resolver functions.

Next, with these files at the ready, in order to use them we need to import and add their contents to our schema.

Importing and adding your types, query resolvers, and mutation resolvers to the schema

Now that we understand how to organize the pieces that make up our schema, let's bring them together so we have a functional schema. Let's take a look at the schema in our example project and see how that maps back to the files we created above.

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: {
    Query: {
      ...DocumentQueries,
    },
    Mutation: {
      ...DocumentMutations,
    },
  },
};

export default makeExecutableSchema(schema);

Hopefully this is starting to make some sense. What you see above is slightly different from what you will find at the file path at the top of this code block. The difference is that here, we've pulled out the parts of the schema related to users to make how the parts we created earlier fit together (these are included as part of the project we cloned from Github).

Starting at the top of the file, in order to create our schema, we import the gql tag from the graphql-tag package (already installed as part of the dependencies in the project we cloned earlier). gql represents a function which takes in a string containing code written in the GraphQL DSL (domain specific language). This is a special syntax that's unique to GraphQL. Because we're using GraphQL within JavaScript, we need a way to interpret that DSL within JavaScript.

The gql function here converts the string we pass it into an AST or abstract syntax tree. This is a large JavaScript object representing a technical map of the contents of the string we passed to gql. Later, when we attach our schema to our GraphQL server, that server implementation will anticipate and understand how to parse that AST.

If we look at where gql is used in the file above, we see that's assigned to the typeDefs property on the object we've stored in the schema variable. In a schema, typeDefs describe the shape of the data that's returned by the server's query and mutation resolvers as well as define the queries and mutation that can be performed.

There are two variations of types: custom types which describe the data in your app and root types. Root types are built-in types that GraphQL reserves for describing the fields available for queries and mutations. More specifically, if we look at the code above, the type Query and type Mutation blocks are two of the three root types available (the third is type Subscription which is used for adding real-time data to a GraphQL server).

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    [...]
  `,
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

To utilize the custom types we wrote earlier (in the /api/documents/graphql/types.js file), at the top of our schema.js file here, we import our types as DocumentTypes. Next, inside of the backticks immediately following our call to gql (the value we're assigning to typeDefs), we use JavaScript string interpolation to concatenate our types into the value we're passing to typeDefs. What this achieves is "loading" our custom types into our GraphQL schema.

Next, in order to define which queries and mutations we can run, we need to define our query fields and mutation fields inside of the root type Query and type Mutation types. Both are defined in the same way. We specify the name of the field that we expect to map to a resolver function in our schema. Optionally, we also describe the arguments or parameters that can be passed to that field from the client.

/api/graphql/schema.js

[...]

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

Here, under type Query, document(documentId: ID!): Document is saying "define a field that will be resolved by a resolver function named document which requires a documentId passed as the scalar type ID and expect it to return data in the shape of the type Document type (added to our schema as part of the ${DocumentTypes} line we concatenated into our typeDefs just inside of the call to gql). We repeat this for each of the fields that we want to make available for querying under type Query.

We repeat the same pattern with the same rules under type Mutation. Like we discussed earlier, the only difference here is that these fields describe mutations that we can run, not queries.

Adding your query and mutation resolvers

Now that we've specified our custom types and the fields in our root type Query and root type Mutation, next, we need to add in the resolver functions that resolve the queries and mutations we defined there. To do it, up at the top of our file, we import our separate queries.js and mutations.js files (remember, these are exporting JavaScript objects) as DocumentQueries and DocumentMutations.

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

import DocumentTypes from "../documents/graphql/types";
import DocumentQueries from "../documents/graphql/queries";
import DocumentMutations from "../documents/graphql/mutations";

const schema = {
  typeDefs: gql`
    ${DocumentTypes}

    type Query {
      document(documentId: ID!): Document
      documents: [Document]
    }

    type Mutation {
      createDocument(document: DocumentInput!): Document
      deleteDocument(documentId: ID!): Document
      updateDocument(documentId: ID!, document: DocumentInput!): Document
    }
  `,
  resolvers: {
    Query: {
      ...DocumentQueries,
    },
    Mutation: {
      ...DocumentMutations,
    },
  },
};

export default makeExecutableSchema(schema);

Next, in the resolvers property on the object we've assigned to the schema variable, we nest two properties: Query and Mutation. These names correspond to the root types we defined in our typeDefs block. Here, resolvers that are associated with the root type Query are set in resolvers.Query object and resolvers that are associated with the root type Mutation are set in the resolvers.Mutation object. Because we exported our DocumentQueries and DocumentMutations as objects, we can "unpack" those objects here using the ... spread syntax in JavaScript.

Like the name implies, this "spreads out" the contents of those objects onto the parent object. Once interpreted by JavaScript, this code will affectively achieve this:

{
  typeDefs: [...],
  resolvers: {
    Query: {
      documents: async (parent, args, context) => {
        return Documents.find({ userId: context.user._id }).toArray();
      },
      document: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        return Documents.findOne({
          _id: args.documentId,
          userId: context.user._id,
        });
      },
    },
    Mutation: {
      createDocument: async (parent, args, context) => {
        const _id = generateId();

        await Documents.insertOne({
          _id,
          userId: context.user._id,
          ...args.document,
          createdAt: new Date().toISOString(),
          updatedAt: new Date().toISOString(),
        });

        return {
          _id,
        };
      },
      updateDocument: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        await Documents.updateOne(
          { _id: args.documentId },
          {
            $set: {
              ...args.document,
              updatedAt: new Date().toISOString(),
            },
          }
        );

        return {
          _id: args.documentId,
        };
      },
      deleteDocument: async (parent, args, context) => {
        await isDocumentOwner(args.documentId, context.user._id);

        await Documents.removeOne({ _id: args.documentId });
      },
    },
  }
}

While we can certainly do this, splitting our queries and resolvers into topics and into their own files makes maintenance far easier (and less overwhelming).

/api/graphql/schema.js

import gql from "graphql-tag";
import { makeExecutableSchema } from "@graphql-tools/schema";

[...]

const schema = {
  typeDefs: [...],
  resolvers: { [...] },
};

export default makeExecutableSchema(schema);

Finally, at the bottom of our file, we export our schema variable, but first wrap in a call to makeExecutableSchema. Similar to the gql function, when we do this, it converts the entirety of our schema into an AST (abstract syntax tree) that can be understood by GraphQL servers and other GraphQL libraries (e.g., GraphQL middleware functions that help with authentication, rate limiting, or error handling).

Technically speaking, with all of that, we have our GraphQL schema! To wrap things up, let's take a look at how our schema is loaded into a GraphQL server.

Adding your schema to a GraphQL server

Fortunately, adding a schema to a server (once the server is defined) only takes two lines: the import of the schema from our /api/graphql/schema.js file and then assigning it to the options for our server.

/api/graphql/server.js

import { ApolloServer } from "apollo-server-express";
import schema from "./schema";
import { isDevelopment } from "../../.app/environment";
import loginWithToken from "../users/token";
import { configuration as corsConfiguration } from "../../middleware/cors";

export default (app) => {
  const server = new ApolloServer({
    schema,
    [...]
  });

  [...]
};

That's it! Keep in mind that the way we're passing our schema here is specific to the Apollo Server library and not necessarily all GraphQL server implementations (Apollo is one of a few GraphQL server libraries).

Wrapping up

In this tutorial, we learned how to organize a GraphQL schema to make maintenance easy. We learned how to parse out the different parts of our GraphQL schema into individual files and separate those files into topics directly related to our data. We also learned how to combine those separate files into a schema and then load that schema into a GraphQL server.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode