Scalable APIs with GraphQL Server Codegen Preset

Eddy Nguyen
Looking for experts? We offer consulting and trainings.
Explore our services and get in touch.

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 (opens in a new tab), 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:

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 (opens in a new tab):

// 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 (opens in a new tab). 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.

mergeResolvers can accidentally override resolvers if there are conflicts in resolver names

Another commonly used feature is mappers (opens in a new tab). 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 (opens in a new tab). 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.

// 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
// 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 in Query/user.ts.
Search for resolver files easily because the filesystem location matches schema location

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 (opens in a new tab) 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:
    yarn add graphql-scalars
    • If the Scalar name does not exist in graphql-scalars, a file is generated for every Scalar type.

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 (opens in a new tab).

Differences between Server Preset and graphql-modules

So far, the preset may sound similar to graphql-modules (opens in a new tab) 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:

At SEEK (opens in a new tab), we are experimenting with this preset and getting positive feedback. Let me know on Twitter (opens in a new tab) if it works for you too!

Join our newsletter

Want to hear from us when there's something new? Sign up and stay up to date!

By subscribing, you agree with Revue’s Terms of Service and Privacy Policy.

Recent issues of our newsletter

Similar articles