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.
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
).
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.