Scalable GraphQL Architecture

Scalable GraphQL Architecture

Hey! everyone

It's often a pain to organize folders and files inside a GraphQL project.

as the API grows, soon you will realize that all the types, resolvers, and other things are missed up and you don't know where to put or find that specific resolver or type.

This is what I have faced and I am pretty sure that every GraphQL developer experienced it. so in this blog, I will show you a simple and scalable architecture that will make your life easier.

for this tutorial, we are using Node.js and Apollo Server. although this knowledge is applicable to other non-opinionated frameworks as well. and also we consider an e-commerce API as an example.

let's get started.

How to set up our project?

You can follow up-to-date installation guide from apollo-server website. there is nothing special about this step.

let's talk about the folder structure

Here is the full view of our scalable directory structure.

src
├── schema
│   ├── product
│   │   ├── db.js
│   │   ├── types.js
│   │   ├── query.js
│   │   ├── mutation.js
│   │   ├── resolvers.js
│   │   └── index.js
│   ├── order
│   │   ├── db.js
│   │   ├── types.js
│   │   ├── query.js
│   │   ├── mutation.js
│   │   ├── resolvers.js
│   │   └── index.js
│   └── index.js
└── app.js

So let's break it down.
for better understanding let's only consider the product part.

   ├── product
      ├── db.js
      ├── types.js
      ├── query.js
      ├── mutation.js
      ├── resolvers.js
      └── index.js

db.js

includes database schema and other helper functions for product.

types.js

include all the GraphQL types, queries, and mutations related to the product.

query.js and mutation.js

include all the query and mutation resolvers related to product.
e.g: productSearch, products, productCreate

resolvers.js

include all the resolvers related to the Product type fields.

index.js

combine all the query, mutation, resolvers, and types.

Now let's have a simple example for each part.

db.js
for this file we use a mongodb schema

// product/db.js
import { schema, model } from 'mongoose'
const schema = new Schema({
       name: String,
       price: Number,
       desc: String,
       reviews: [{
          type: Schema.types.ObjectID,
          ref: "Review"
       }]
       ...
})
// helper function
export const aggregateProduct = () => {
     // whatever
}
export const ProductModel = model("product", schema, "product")

types.js

export const ProductTypes = `
    type Product {
      id: ID
      name: String
      price: Float
      desc: String
      # Review type is inside it's own folder so we don't declare it inside this file
      reviews: [Review]
    }
    extend type Query {
      product: Product
      products: [Product]
    }
    input ProductCreateDataInput {
        ....
    }
    input ProductUpdateDataInput {
       ....
    }
    input ProductUpdateWhereInput {
       ....
    }
    extend type Mutation {
      productCreate(data: ProductCreateDataInput!): Product
      productUpdate(data: ProductUpdateDataInput!, where: ProductUpdateWhereInput!): Product 
    }
`

query.js

// product/query.js
export const ProductQuery = {
       product: (parent, args) => {
             // whatever
       },
       prouducts: (parent, args) => {
            // whatever
       }
}

mutation.js

// product/mutation.js
export const ProductMutation = {
       productCreate: (parent, args) => {
             // whatever
       },
       prouductUpdate: (parent, args) => {
            // whatever
       }
}

resolvers.js
in this file, we include any resolver needed for any field of our type. if you don't provide any resolver, the apollo server will consider a default resolver for it which is fieldname: (parent) => parent.fieldname

// product/resolvers.js
export const ProductResolvers = {
    // here we only write a resolver for reviews and apollo server will create a default 
    // resolver for other fields.
    reviews: (parent, args) => {
        // whatever
    }
}

index.js

// product/index.js
export { ProductModel } from 'db.js'
export { ProductTypes } from 'types.js'
export { ProductQuery } from 'query.js'
export { ProductMutation } from 'mutation.js'
export { ProductResolvers } from 'resolvers.js'
Now we have all the things separated in their own file. and we can repeat the same steps for order, review, company, and any other domain that we have.

Ok let's see to whole structure again

src
├── schema
│   ├── product
│   │   ├── db.js
│   │   ├── types.js
│   │   ├── query.js
│   │   ├── mutation.js
│   │   ├── resolvers.js
│   │   └── index.js
│   ├── order
│   │   ├── db.js
│   │   ├── types.js
│   │   ├── query.js
│   │   ├── mutation.js
│   │   ├── resolvers.js
│   │   └── index.js
│   └── index.js
└── app.js

Now inside src/schema/index.js we combine all the things from product, order, or any domain that we have.

src/schema/index.js

import { gql } from 'apollo-server-express'
import { ProductTypes, ProductQuery, ProductMutation, ProductResolvers } from './product'
import { OrderTypes, OrderQuery, OrderMutation, OrderResolvers } from './order'
import { ReviewTypes, ReviewQuery, ReviewMutation, ReviewResolvers } from './review'

// remember we only use gql in this file. types in other files are just simple strings
export const typeDefs = gql`
     type Query
     type Mutation
     ${ProductTypes}
     ${OrderTypes}
     ${ReviewTypes}
`
export const resolvers = {
   Query: {
       ... ProductQuery,
       ... OrderQuery,
       ... ReviewQuery
   },
   Mutation: {
       ... ProductMutation,
       ... OrderMutation,
       ... ReviewMutation
   },
   Product: ProductResolvers,
   Order: OrderResolvers,
   Review: ReviewResolvers
}

and now simply import typeDefs and resolvers inside app.js

app.js
import { ApolloServer } from 'apollo-server-express'
import { typeDefs, resolvers } from './schema'

 /** 
/ * for simplicity some lines are not included.
/ * so please follow apollo-server website on how to setup 'app.js' file.
  */
const server = new ApolloServer({ typeDefs, resolvers });

Everything is ok till now.
but still, there is an issue. as you can see we just combined all the resolver functions inside a single object. for example, product/query.js file has all the resolvers inside a single object. and it's not good enough when you have for example 10 resolver functions. so the solution for this issue is to create a folder instead of a file.
here is an example.

   ├── query
      ├── product.js
      ├── products.js
      ├── productsOutOfStock.js
      └── index.js

Ok, now combine all the functions inside the index.js.

here are some examples of how this solution is working.
query/prouduct.js

// query/proudct.js
export const product = (parent, args) => { // whatever }

query/index.js

import { product } from './product.js'
import { products } from './products.js'
import { productsOutOfStock } from './productsOutOfStock'

export const ProductQuery = {
     product: product,
     // shorthand
     products,
     proudctsOutOfStock
}

And the same rule is applicable to the mutation or resolvers file.

the last thing that remained in this Architecture, is where to store shared things for example shared GraphQL enums between order and product. which we can't relate them to order or product by domain. so the solution is really straightforward. create a folder by the name of shared inside src/schema/ and then store shared stuff in there.

Bonus stuff

  1. use [name][verb] syntax when naming your queries or mutations. it will help to easily find a query or even easily name them. Example
    Mutation {
      productCreate(.....): Product
      productUpdate(....): Product
    }
    
  2. use [fieldname][argName]input for your args. Example
    input ProductsWhereInput { ... }
    Query {
      products(where: ProductsWhereInput!): [Products!]
    }
    

Congratulation 🥳

You are now a GraphQL Ninja. I am really proud of you.

if you found it helpful. please share it! and let your friends also know these things.

Thank you very much for reading this blog post.
Follow me on Twitter and let's be friends.