Production Ready GraphQL

Part 1: Project Structure

The key to scale is grouping related code by feature, not by responsibility. I first saw this pattern when working with Redux; it's sometimes referred to as the feature folder approach or simply the ducks pattern.

co-locating logic for a given feature in one place typically makes it easier to maintain that code

https://redux.js.org/style-guide/style-guide#structure-files-as-feature-folders-with-single-file-logic

This pattern also works incredibly well for GraphQL. Imagine we have a schema with many nodes. Like User, Post, Comment etc.

type User {
  id: ID!
  name: String!
  posts: [Post!]!
  comments: [Comment!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  text: String!
  user: User!
  post: Post!
}

The graph we design here could be connected in many ways.

  • A User could create many posts and leave many comments
  • A Post is created by a user and has many comments
  • A Comment is created by a user and belongs to a post

As the graph grows, the possible relationships between nodes can increase exponentially. In terms of code structure, it becomes impractical to make any assumptions about these relationships.

The best approach here is to keep nodes separate. The folder structure for this schema would look as follows:

src/
  /nodes
    /User
      /schema.graphql
      /resolvers.ts
      /index.ts
    /Post
      /schema.graphql
      /resolvers.ts
      /index.ts
    /Comment
      /schema.graphql
      /resolvers.ts
      /index.ts

Note: All code within each node folder should be considered private, with only the index responsible for exposing anything publicly.

Diving into the Post node let's take a look at each file.

schema.graphql

type Post {
  id: ID!
  title: String!
  author: User!
  comments: [Comment!]!
}

type Query {
  post(id: ID!): Post!
  posts: [Post!]!
}

type Mutation {
  createPost(title: String!): Post!
}

The schema defines only its own node and any Queries or Mutations for that node. Anything else is defined within schema files for other nodes.

resolvers.ts

const resolvers = {
  Post: {
    author: () => {},
    comments: () => {},
  },
  Query: {
    post: () => {},
    posts: () => {},
  },
  Mutation: {
    createPost: () => {},
  },
};

export default resolvers;

The resolvers define only the fields for this node. We also define any nested resolvers but never go deeper than one level. i.e we only care about the direct relationships to the Post node and nothing else.

How does this structure help?

  1. Structuring our code this way will completely decouple features, meaning teams can collaborate on a large codebase without causing frustrating code conflicts. Any changes would have minimal effect across the rest of the project.

  2. The schema and resolvers will almost always change together, so it's much easier to work on a particular feature when everything is in one place.

  3. By defining nested resolvers of only one level deep, it becomes obvious where you can find the implementation for a given relationship.

With resolvers and schemas separated we need a way to glue them all together. This can be achieved with the "merge" methods from graphql-tools.

import { mergeTypeDefs, mergeResolvers } from "@graphql-tools/merge";
import { resolvers as postResolvers, schema as postSchema } from "./nodes/Post";
import {
  resolvers as commentResolvers,
  schema as commentSchema,
} from "./nodes/Comment";
import { resolvers as userResolvers, schema as userSchema } from "./nodes/User";

const types = mergeTypeDefs([postSchema, commentSchema, userSchema]);
const resolvers = mergeResolvers([
  postResolvers,
  commentResolvers,
  userResolvers,
]);

More detail on how merging works can be seen on the graphql-tools docs