Scalable APIs with GraphQL Server Codegen Preset
Tags: graphql, codegen, node, server
Published: 2023-01-24 08:12:12

> This article was published on Tuesday, January 24, 2023 by [Eddy Nguyen](https://twitter.com/eddeee888) @ [The Guild Blog](https://the-guild.dev/)

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:

```text
├── 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:

```text
├── 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:

```text
├── 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](https://the-guild.dev/graphql/codegen), right? 😉).

```text
├── 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](https://the-guild.dev/graphql/codegen) with
    [typescript](https://the-guild.dev/graphql/codegen/plugins/typescript/typescript) and
    [typescript-resolvers](https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-resolvers)
    plugins for type-safe GraphQL server development in TypeScript
*   [GraphQL ESLint](https://github.com/dimaMachina/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](https://the-guild.dev/graphql/yoga-server):

```ts
// 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](https://the-guild.dev/graphql/tools/docs/schema-merging#merging-resolvers).
This means the Maintainers are notified on every new module instead of every new resolver:

```ts
// 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.

<img alt="mergeResolvers can accidentally override resolvers if there are conflicts in resolver names" src="/blog-assets/scalable-apis-with-graphql-server-codegen-preset/merge-resolvers.gif" style={{ width: '80%', margin: '0 auto' }} />

Another commonly used feature is
[mappers](https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-resolvers#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:

```yaml
# 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](https://www.npmjs.com/package/@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:

```bash
yarn add -D @graphql-codegen/cli @eddeee888/gcg-typescript-resolver-files
```

Then, you can add the following config:

```yaml
# 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:

```text
├── src/
│   ├── schema/
│   │   ├── base/
│   │   │   ├── schema.graphql
│   │   ├── user/
│   │   │   ├── schema.graphql
│   │   ├── book/
│   │   │   ├── schema.graphql
```

Given the following content of schema files:

```graphql
# 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:

```bash
yarn codegen
```

We will see the following files:

```text
├── 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`

```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`.

<img alt="Search for resolver files easily because the filesystem location matches schema location" src="/blog-assets/scalable-apis-with-graphql-server-codegen-preset/filesystem-resolvers.png" />

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:

```ts
// 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](https://gqlgen.com/) 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](https://the-guild.dev/graphql/scalars), it
        is automatically imported from `graphql-scalars` into the resolver map. Make sure to install it:
    ```bash
    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.

```ts
// 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](https://github.com/eddeee888/graphql-code-generator-plugins/tree/master/packages/typescript-resolver-files).

### Differences between Server Preset and `graphql-modules`

So far, the preset may sound similar to [graphql-modules](https://the-guild.dev/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](https://www.npmjs.com/package/@eddeee888/gcg-typescript-resolver-files)

At [SEEK](https://www.seek.com.au/), we are experimenting with this preset and getting positive
feedback. [Let me know on Twitter](https://twitter.com/eddeee888) if it works for you too!