DEV Community

Cover image for Scalable APIs with GraphQL Server Codegen Preset
TheGuildBot for The Guild

Posted on • Updated on • Originally published at the-guild.dev

Scalable APIs with GraphQL Server Codegen Preset

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

// 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')
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, you can add the following config:

# codegen.yml
schema: 'src/**/*.graphql'
generates:
  src/schema:
    preset: '@eddeee888/gcg-typescript-resolver-files'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

When we run codegen:

yarn codegen
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode
  • 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 */
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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.

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 }
Enter fullscreen mode Exit fullscreen mode

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:

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)