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
- use
[name][verb]
syntax when naming your queries or mutations. it will help to easily find a query or even easily name them. ExampleMutation { productCreate(.....): Product productUpdate(....): Product }
- use
[fieldname][argName]input
for your args. Exampleinput 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.