This article was published on Tuesday, January 24, 2023 by Eddy Nguyen @ The Guild Blog
A GraphQL server is usually a central system where teams need to be able to develop features whilst
not blocking other teams. Each team can have varied standards and practices. So, if the GraphQL
server is not set up in a structure to allow concurrent contribution, development slows down, and
more time is spent on admin tasks rather than delivering new features.
This blog post explores some of the common problems GraphQL servers encounter at scale and
recommends how to solve them.
The Easy Problem: Code Ownership
Problem: How to Manage Code Ownership?
Sharing a single codebase across many teams without clear structure and guidelines is a recipe for
disaster. The first team in the codebase usually establishes a structure that works for them. When a
second team joins, they most likely follow the structure already there. The same story happens for
every team thereafter. After a few more rounds, development slows down. One may review the structure
and find themselves staring down a codebase structured by one team, for one team.
Teams want their members to be notified of changes related to their domain, but not every Pull
Request (PR). If you were ever notified of a change unrelated to your team, chances are the codebase
needs to be set up to support many teams working on it.
In the example below, Team A is the first to set up the server. They manage User
and Auth
domains, so they create datasources
and resolvers
folders for these files:
├── src/
│ ├── schema/
│ │ ├── datasources/
│ │ │ ├── UserDatasource.ts
│ │ │ ├── AuthDatasource.ts
│ │ ├── resolvers/
│ │ │ ├── userResolvers.ts
│ │ │ ├── authResolvers.ts
│ │ ├── userSchema.graphql
│ │ ├── authSchema.graphql
│ ├── server.ts
│ ├── codegen.yml
Then, Team B comes into the codebase. They manage Book
domain, so they add their files following
the same structure:
├── src/
│ ├── schema/
│ │ ├── datasources/
│ │ │ ├── UserDatasource.ts
│ │ │ ├── AuthDatasource.ts
│ │ │ ├── BookDatasource.ts
│ │ ├── resolvers/
│ │ │ ├── userResolvers.ts
│ │ │ ├── authResolvers.ts
│ │ │ ├── bookResolvers.ts
│ │ ├── userSchema.graphql
│ │ ├── authSchema.graphql
│ │ ├── bookSchema.graphql
│ ├── server.ts
│ ├── codegen.yml
While this works at a small scale with low communication overhead, it cannot scale. When there are
tens or hundreds of datasources and resolvers, it would be hard to know who owns what. A simple
solution is to assign files to owners using GitHub's CODEOWNERS or similar features. But it has to
be done on every file because files are split into category folders, e.g. resolvers
,
datasources
, etc.
It is tempting to split this structure up into folders, each named after the team that manages it:
├── src/
│ ├── schema/
│ │ ├── TeamA/ # Team A notified (by CODEOWNERS) if changes happen in this folder
│ │ │ ├── datasources/
│ │ │ │ ├── UserDatasource.ts
│ │ │ │ ├── AuthDatasource.ts
│ │ │ ├── resolvers/
│ │ │ │ ├── userResolvers.ts
│ │ │ │ ├── authResolvers.ts
│ │ │ ├── userSchema.graphql
│ │ │ ├── authSchema.graphql
│ │ ├── TeamB/ # Team B notified (by CODEOWNERS) if changes happen in this folder
│ │ │ ├── datasources/
│ │ │ │ ├── BookDatasource.ts
│ │ │ ├── resolvers/
│ │ │ │ ├── bookResolvers.ts
│ │ │ ├── bookSchema.graphql
│ ├── server.ts
│ ├── codegen.yml
This is already a significant improvement as ownership is defined, but organisational problems
remain. For example, what happens when Team A changes what they own, splits into smaller teams or
changes its name? All these scenarios need admin work: renaming the folder in the best case and
moving files around in the worst. Admin work like this creates no value for end users.
On top of that, all datasources and resolvers must be put together into the GraphQL server. It is
the server.ts
file in this example. Who owns this file and other server maintenance such as
package updates, security patches, codegen.yml
etc.?
Solution: Split into Modules
It is common to see teams change their name, divide as teams scale, or combine as structure and
priorities change. Yet, the thing that generally remains stable over long periods is the business
domain. If we split our schema based on the business domain and assign teams accordingly, it becomes
much more scalable. If a team needs to hand over a domain to another team, they only need to update
the CODEOWNERS file.
It is best to have a small group of dedicated maintainers for server maintenance. This could consist
of one member from each team in the codebase, and its membership can rotate. Alternatively, some
companies may choose to assign this responsibility to a dedicated team. Having a dedicated group -
let's call them the Maintainers - helps reduce noise and cognitive load for the rest of the teams,
allowing them to focus on delivering features.
Using the same example before, we can identify 3 main domains: User
, Auth
and Book
, each
having CODEOWNERS set up to notify appropriate teams of changes. The Maintainers own server.ts
and
other configs like codegen.yml
(We all use
GraphQL Codegen, right? 😉).
├── src/
│ ├── schema/
│ │ ├── user/ # Team A notified if changed
│ │ │ ├── datasources.ts
│ │ │ ├── resolvers.ts
│ │ │ ├── schema.graphql
│ │ ├── auth/ # Team A notified if changed
│ │ │ ├── datasources.ts
│ │ │ ├── resolvers.ts
│ │ │ ├── schema.graphql
│ │ ├── book/ # Team B notified if changed
│ │ │ ├── datasources.ts
│ │ │ ├── resolvers.ts
│ │ │ ├── schema.graphql
│ ├── server.ts # Maintainers notified if changed
│ ├── codegen.yml # Maintainers notified if changed
The Hard Problem: Best Practice Alignment at Scale
Splitting schema into modules is usually easy to get teams to agree on. Then we start to see the
hard problems: how to enforce best practices to the teams while reducing the time Maintainers need
to spend on server maintenance.
1. How to Enforce Best Practices for All Teams
Bad practices and conventions spread like bushfire on a hot summer day in Australia; it is the
worst! I used to be part of a Maintainers team. We had guidelines on various topics, one being
resolver naming convention. On one occasion, a developer incorrectly used pascal case instead of
camel case. The following day I woke up with more than half of the resolvers in pascal case. 😱
OK, I am exaggerating here, but the experience was very traumatising.
Guidelines are only good if people follow them. Standards start to slip without explicit and
automatic enforcement, and bad practices begin to spread.
We need automated tools to enforce guidelines effectively. Luckily, these days we have an extensive
range of tools to help with GraphQL server best practices:
- GraphQL Codegen with typescript and typescript-resolvers plugins for type-safe GraphQL server development in TypeScript
- GraphQL ESLint for validation, linting, and checking for best practices and conventions (like all resolvers must be camel case!!! 💀).
However, there are areas to improve.
For example, the guideline for one of the GraphQL servers I am working on is to use generated types
from GraphQL Codegen. Some team members, particularly those new to the codebase, may have yet to be
aware of this. Luckily, other team members or the Maintainers catch these issues at PR review time.
But it would save everyone time and effort if we had tools to enforce this guideline automatically.
2. How to Minimise Noise to the Maintainers
Maintainers usually need to manage these aspects of a GraphQL server:
- Core server logic: resolver map, schemas, CI/CD, etc.
- Config files:
.graphqlrc
,codegen.yml
, etc.
Changing anything in the Maintainers' domain should notify them. Unfortunately, this happens
regularly in routine workflows. Maintainers also have their team's work and general package and
security updates to worry about. So, being notified of unrelated PRs quickly leads to burnout.
For example, if a new resolver is added, it must be manually added to the resolver map. So,
Maintainers are notified of every new resolver. It may look like this if you are using
GraphQL Yoga:
// server.ts (managed by the Maintainers)
import { createServer } from 'node:http'
import { createSchema, createYoga } from 'graphql-yoga'
import { Auth } from "./schema/auth/resolvers";
import { book, Book } from "./schema/auth/resolvers";
import { user, User } from "./schema/user/resolvers";
const schema = createSchema({
typeDefs: `...`,
resolvers: { // This is the resolver map
Query: {
user,
book,
},
Auth,
Book
User,
}
});
const yoga = createYoga({ schema });
const server = createServer(yoga)
server.listen(4000, () => {
console.info('Server is running on http://localhost:4000/graphql')
})
There is a way to mitigate this issue: each schema module exports an object of resolvers, and they
are passed into resolvers
as an array. Internally, the resolvers are merged using
mergeResolvers from @graphql-tools/merge.
This means the Maintainers are notified on every new module instead of every new resolver:
// server.ts
import { createServer } from 'node:http'
import { createSchema, createYoga } from 'graphql-yoga'
import * as authResolvers from './schema/auth/resolvers'
import * as bookResolvers from './schema/book/resolvers'
import * as userResolvers from './schema/user/resolvers'
const schema = createSchema({
typeDefs: `...`,
resolvers: [
// mergeResolvers are called internally
authResolvers,
bookResolvers,
userResolvers
]
})
// rest of server config
However, mergeResolvers
has a caveat: it is simply merging plain JavaScript objects at runtime.
Thus, there is a risk of someone accidentally overriding others' resolvers. This issue is hard to
find in large codebases with hundreds of resolvers and modules.
Another commonly used feature is
mappers.
This feature allows resolvers to return custom mapper objects instead of GraphQL output types.
The problem with mappers is that we need to update codegen.yml
every time we need to create one:
# codegen.yml
generates:
src/schema/types.generated.ts:
plugins:
- typescript
- typescript-resolvers
mappers:
User: './mappers#UserMapper'
Profile: './mappers#ProfileMapper'
# Add another line for each mapper
This is a problem for the Maintainers because these changes are based on team requirements. Whether
a team uses mappers or not is the team's choice and should not concern the Maintainers. Yet, the
Maintainers are notified because they own the codegen.yml
file.
Solution: Use GraphQL Server Codegen Preset
To solve the above problems, I am working on a codegen preset for GraphQL server:
@eddeee888/gcg-typescript-resolver-files.
The aim is to move from guidelines/config to conventions. All changes happen inside teams' modules,
so feature work does not notify the Maintainer.
This works for any GraphQL server implementation, such as GraphQL Yoga, Apollo Server, etc.
Here's how to get started:
yarn add -D @graphql-codegen/cli @eddeee888/gcg-typescript-resolver-files
Then, you can add the following config:
# codegen.yml
schema: 'src/**/*.graphql'
generates:
src/schema:
preset: '@eddeee888/gcg-typescript-resolver-files'
Note that this preset includes @graphql-codegen/typescript
and
@graphql-codegen/typescript-resolvers
under the hood, so you do not have to set that up manually!
Now, all we have to do is to set up schema modules like this:
├── src/
│ ├── schema/
│ │ ├── base/
│ │ │ ├── schema.graphql
│ │ ├── user/
│ │ │ ├── schema.graphql
│ │ ├── book/
│ │ │ ├── schema.graphql
Given the following content of schema files:
# src/schema/base.graphql
type Query
type Mutation
# src/schema/user.graphql
extend type Query {
user(id: ID!): User
}
type User {
id: ID!
fullName: String!
}
# src/schema/book.graphql
extend type Query {
book(id: ID!): Book
}
extend type Mutation {
markBookAsRead(id: ID!): Book!
}
type Book {
id: ID!
isbn: String!
}
When we run codegen:
yarn codegen
We will see the following files:
├── src/
│ ├── schema/
│ │ ├── base/
│ │ │ ├── schema.graphql
│ │ ├── user/
│ │ │ ├── resolvers/
│ │ │ │ ├── Query/
│ │ │ │ │ ├── user.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── User.ts # Generated, changes not overwritten by codegen
│ │ │ ├── schema.graphql
│ │ ├── book/
│ │ │ ├── resolvers/
│ │ │ │ ├── Query/
│ │ │ │ │ ├── book.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── Mutation/
│ │ │ │ │ ├── markBookAsRead.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── Book.ts # Generated, changes not overwritten by codegen
│ │ │ ├── schema.graphql
│ │ ├── types.generated.ts # Entirely generated by codegen
│ │ ├── resolvers.generated.ts # Entirely generated by codegen
Generated Files
Shared schema and resolver TypeScript types:
types.generated.ts
. This is generated by
@graphql-codegen/typescript
and@graphql-codegen/typescript-resolvers
plugins. This can be
ignored in Git or removed from CODEOWNERS because it is entirely generated.Resolver map:
resolvers.generated.ts
. This puts all other resolvers together statically,
ready to be used by the GraphQL server. This can be ignored in Git or removed from CODEOWNERS
because it is entirely generated.
{/* prettier-ignore */}
```ts filename="src/schema/resolvers.generated.ts"
/* This file was automatically generated. DO NOT UPDATE MANUALLY. */
import type { Resolvers } from './types.generated'
import { book as Query_book } from './book/resolvers/Query/book'
import { markBookAsRead as Mutation_markBookAsRead } from './book/resolvers/Mutation/markBookAsRead'
import { Book } from './book/resolvers/Book'
import { user as Query_user } from './user/resolvers/Query/user'
import { User } from './user/resolvers/User'
export const resolvers: Resolvers = {
Query: {
book: Query_book,
user: Query_user
},
Mutation: {
markBookAsRead: Mutation_markBookAsRead
},
Book: Book,
User: User
}
* **Operation resolvers**:
* `src/schema/user/resolvers/Query/user.ts`
* `src/schema/book/resolvers/Query/book.ts`
* `src/schema/book/resolvers/Mutation/book.ts`
```ts
// Example: src/schema/user/resolvers/Query/user.ts
import type { QueryResolvers } from './../../../types.generated'
export const user: NonNullable<QueryResolvers['user']> = async (_parent, _arg, _ctx) => {
/* Implement Query.user resolver logic here */
}
- Object type resolvers:
-
src/schema/user/resolvers/User.ts
-
src/schema/book/resolvers/Book.ts
-
// Example: src/schema/user/resolvers/User.ts
import type { UserResolvers } from './../../types.generated'
export const User: UserResolvers = {
/* Implement User resolver logic here */
}
Resolvers are generated with developer experience in mind:
- Automatically typed: you can go straight into implementing resolver logic.
- Location in the schema matches the generated location on the filesystem: you can easily jump to a
resolver file. For example,
user
Query logic can be found inQuery/user.ts
.
The resolver files are not overwritten when codegen runs again. However, there are some smarts
built-in to ensure resolvers are correctly exported. For example, if we rename User
resolver to
WrongUser
in src/schema/user/resolvers/User.ts
, and then run codegen, the file will be updated
with a warning:
// Example: src/schema/user/resolvers/User.ts
import type { UserResolvers } from './../../types.generated'
export const WrongUser: UserResolvers = {
/* Implement User resolver logic here */
}
/* WARNING: The following resolver was missing from this file. Make sure it is properly implemented or there could be runtime errors. */
export const User: UserResolvers = {
/* Implement User resolver logic here */
}
Some of these features are inspired by gqlgen so check it out if you need a
Golang GraphQL server implementation.
Other GraphQL Types
These other types are also supported by the preset:
- Union: A file is generated for every Union type.
-
Scalars:
- If the Scalar name matches one in graphql-scalars, it
is automatically imported from
graphql-scalars
into the resolver map. Make sure to install it:
yarn add graphql-scalars
- If the Scalar name does not exist in
graphql-scalars
, a file is generated for every Scalar type.
- If the Scalar name matches one in graphql-scalars, it
is automatically imported from
For other currently non-supported types, we can declare them using the externalResolvers
preset
config.
Mappers Convention
Mappers can be added by exporting types or interfaces with Mapper
suffixes from .mappers.ts
files in each module. For example, UserMapper
will be used as User
's mapper type.
// src/schema/user/schema.mappers.ts
// This works! This will be used as mapper for `User` object type
export { User as UserMapper } from 'external-module'
// This 1 works! For `User1` object type
export interface User1Mapper {
id: string
}
// This works 2! For `User2` object type
export type User2Mapper = { id: string }
// This works 3! For `User3` object type
interface User3Mapper {
id: string
}
export { User3Mapper }
Gradual Migration Supported
If you have an existing codebase in a modularised structure but cannot migrate all at once, the
preset has whitelistedModules
and blacklistedModules
options to support gradual migration.
Customisable Conventions
All mentioned conventions are customisable! Check out the documentation for
more options.
Differences between Server Preset and graphql-modules
So far, the preset may sound similar to graphql-modules as
they both split schemas into modules. Yet, they solve different problems.
graphql-modules
is a modularisation utility library that allows each module to maintain schema
definitions and resolvers separately, whilst serving a unified schema at runtime.
The preset focuses on conventions (such as file structures, types, integrations with other
libraries, etc.). However, it does not force schema modularisation upon the user. In fact, the
default modules
mode is recommended for its scalablility and simplicity. The preset also has a
merged
mode to generate files in a monolithic way that may suit some teams and organisations.
This also means there could be a mode in the preset to support graphql-modules
in the future.
Summary
In this blog post, we have explored the problems likely to occur if your GraphQL server is not
prepared for scale:
- unclear ownership
- ignored best practices
- noisy maintenance
We can solve these problems by following the outlined recommendations:
- modularise the codebase based on business domains
- use the GraphQL server codegen preset @eddeee888/gcg-typescript-resolver-files
At SEEK, we are experimenting with this preset and getting positive
feedback. Let me know on Twitter if it works for you too!
Top comments (0)