A Fast GraphQL API With Fastify and Yoga · Part 3 of 8 in the series
Part 3: Schema First, Typed by Codegen
Write the GraphQL schema of the guild board, let GraphQL Codegen generate the resolver types from it, answer queries from PostgreSQL and find the N+1 problem in the SQL log.

Quest objectives
- Describe an API in the GraphQL schema language
- Generate resolver types with GraphQL Codegen and mappers
- Write typed resolvers that read with Drizzle
- Recognise the N+1 problem in the query log
A GraphQL API starts with its schema. You can build it in code, or write it in the schema definition language, SDL, and add the code that answers it. This series writes SDL: it is the contract between the API and its clients, short enough for anyone on the team to read, and GraphQL Codegen turns it into TypeScript types, so the resolvers cannot drift away from it.
The schema
Create src/schema.graphql:
type Query {
guilds: [Guild!]!
guild(id: ID!): Guild
quests(status: QuestStatus, first: Int! = 20): [Quest!]!
quest(id: ID!): Quest
}
type Guild {
id: ID!
name: String!
motto: String
adventurers: [Adventurer!]!
quests(status: QuestStatus): [Quest!]!
}
type Adventurer {
id: ID!
name: String!
class: AdventurerClass!
level: Int!
guild: Guild!
quests: [Quest!]!
}
type Quest {
id: ID!
title: String!
reward: Int!
status: QuestStatus!
guild: Guild!
assignee: Adventurer
}
enum AdventurerClass {
WARRIOR
MAGE
ROGUE
RANGER
}
enum QuestStatus {
OPEN
ACCEPTED
DONE
}Query holds the entry points: every query starts at one of its fields. An exclamation mark means a value is never null, square brackets mean a list. quests takes an optional status and a page size, first, which is 20 if the client sends nothing. The other types are the guild board from the last part, with the relations as fields: a quest has a guild and maybe an assignee, a guild has adventurers and quests.
GraphQL Codegen
Install the CLI and two plugins:
$ pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers[WARN] 3 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5, node-domexception@1.0.0Packages: +238Progress: resolved 238, reused 41, downloaded 238, added 238, donedevDependencies:+ @graphql-codegen/cli 7.4.6+ @graphql-codegen/typescript 6.1.2+ @graphql-codegen/typescript-resolvers 6.1.1Done in 616ms using pnpm v12.16.1
Configure it in codegen.ts:
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'src/schema.graphql',
generates: {
'src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-resolvers'],
config: {
useTypeImports: true,
enumsAsTypes: true,
contextType: '../context.ts#Context',
mappers: {
Guild: '../db/schema.ts#GuildRow',
Adventurer: '../db/schema.ts#AdventurerRow',
Quest: '../db/schema.ts#QuestRow',
},
scalars: {
ID: { input: 'string', output: 'string | number' },
},
},
},
},
};
export default config;typescriptwrites a type for every type in the schema, andtypescript-resolverswritesResolvers, the type of the object that answers them.useTypeImportsandenumsAsTypeskeep the generated file to syntax that Node can strip:import type, and enums as unions of strings, which match the values of the PostgreSQL enums exactly.contextTypeis the context every resolver gets. You write it in a moment.mappersare the important part. By default Codegen assumes that a resolver returns the GraphQL type itself, so a quest would need aguildobject. Your resolvers return database rows, though, and a row hasguildId. The mappers say so: whenever a field returns aQuest, aQuestRowis what you hand over, and it is also the parent that the fields ofQuestreceive.scalarsaccepts numbers forIDin the answers. The ids in the database are numbers, and GraphQL sends them as strings.
Add the script and run it:
"codegen": "graphql-codegen",$ pnpm codegen✔ Parse Configuration✔ Load GraphQL schemas✔ Load GraphQL documents✔ Generate✔ Generate to src/generated/graphql.ts✔ Generate outputs
This is part of what it wrote to src/generated/graphql.ts: the parents are your rows, and every field of a quest has a typed resolver.
export type ResolversParentTypes = {
Adventurer: AdventurerRow;
Boolean: Scalars['Boolean']['output'];
Guild: GuildRow;
ID: Scalars['ID']['output'];
Int: Scalars['Int']['output'];
Query: Record<PropertyKey, never>;
Quest: QuestRow;
String: Scalars['String']['output'];
};
export type QuestResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Quest'] = ResolversParentTypes['Quest']> = {
assignee?: Resolver<Maybe<ResolversTypes['Adventurer']>, ParentType, ContextType>;
guild?: Resolver<ResolversTypes['Guild'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
reward?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['QuestStatus'], ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
};Run pnpm codegen again whenever you change the schema. Commit the generated file, or run Codegen before the type check in CI, so the types always match the schema.
The context
Every resolver gets a context: what Yoga puts in, the Fastify request and reply, and whatever you add. Create src/context.ts:
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { YogaInitialContext } from 'graphql-yoga';
import { db, type Database } from './db/index.ts';
export interface ServerContext {
req: FastifyRequest;
reply: FastifyReply;
}
export interface Context extends YogaInitialContext, ServerContext {
db: Database;
}
export function createContext(): Pick<Context, 'db'> {
return { db };
}createContext runs for every request and returns only your part. Yoga merges it with the rest. For now that is the database.
The resolvers
Create src/resolvers.ts:
import { and, eq } from 'drizzle-orm';
import { GraphQLError } from 'graphql';
import type { Database } from './db/index.ts';
import { adventurers, guilds, quests } from './db/schema.ts';
import type { Resolvers } from './generated/graphql.ts';
function badInput(message: string) {
return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } });
}
async function findGuild(db: Database, id: number) {
const [guild] = await db.select().from(guilds).where(eq(guilds.id, id));
if (!guild) throw new Error(`Guild ${id} not found`);
return guild;
}
async function findAdventurer(db: Database, id: number) {
const [adventurer] = await db.select().from(adventurers).where(eq(adventurers.id, id));
if (!adventurer) throw new Error(`Adventurer ${id} not found`);
return adventurer;
}
export const resolvers: Resolvers = {
Query: {
guilds: (_parent, _args, { db }) => db.select().from(guilds).orderBy(guilds.id),
guild: async (_parent, { id }, { db }) => {
const [guild] = await db.select().from(guilds).where(eq(guilds.id, Number(id)));
return guild ?? null;
},
quests: (_parent, { status, first }, { db }) => {
if (first < 1 || first > 100) throw badInput('first must be between 1 and 100');
return db
.select()
.from(quests)
.where(status ? eq(quests.status, status) : undefined)
.orderBy(quests.id)
.limit(first);
},
quest: async (_parent, { id }, { db }) => {
const [quest] = await db.select().from(quests).where(eq(quests.id, Number(id)));
return quest ?? null;
},
},
Guild: {
adventurers: (guild, _args, { db }) =>
db.select().from(adventurers).where(eq(adventurers.guildId, guild.id)).orderBy(adventurers.id),
quests: (guild, { status }, { db }) =>
db
.select()
.from(quests)
.where(and(eq(quests.guildId, guild.id), status ? eq(quests.status, status) : undefined))
.orderBy(quests.id),
},
Adventurer: {
guild: (adventurer, _args, { db }) => findGuild(db, adventurer.guildId),
quests: (adventurer, _args, { db }) =>
db.select().from(quests).where(eq(quests.assigneeId, adventurer.id)).orderBy(quests.id),
},
Quest: {
guild: (quest, _args, { db }) => findGuild(db, quest.guildId),
assignee: (quest, _args, { db }) =>
quest.assigneeId === null ? null : findAdventurer(db, quest.assigneeId),
},
};A resolver gets four arguments: the parent object, the field's arguments, the context and information about the query. Fields that are a column of the row, like title or reward, need no resolver at all: GraphQL reads them from the row by name. Only the relations need code.
A GraphQLError goes to the client as it is, with its code. Every other error is masked: the client gets Unexpected error. and the details go to the log, so the API never shows anyone a stack trace or a piece of SQL. That is why badInput exists, and why the check of first uses it: without it a first of -1 would reach PostgreSQL.
Put the schema file and the resolvers together in src/schema.ts:
import { readFileSync } from 'node:fs';
import { createSchema } from 'graphql-yoga';
import type { Context } from './context.ts';
import { resolvers } from './resolvers.ts';
export const schema = createSchema<Context>({
typeDefs: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
resolvers,
});And use it, with the context, in src/server.ts:
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { createContext, type ServerContext } from './context.ts';
import { schema } from './schema.ts';
const app = Fastify({
logger: true,
logController: new LogController({ disableRequestLogging: true }),
});
const yoga = createYoga<ServerContext>({
schema,
context: createContext,
logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});
app.route({
url: yoga.graphqlEndpoint,
method: ['GET', 'POST', 'OPTIONS'],
handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});
await app.listen({ port: Number(process.env.PORT) });Two last changes. node --watch only notices changes to files that are imported, and schema.graphql is read, not imported. Watch the whole src folder instead:
"dev": "node --env-file=.env --watch-path=src src/server.ts | pino-pretty",
"codegen": "graphql-codegen",And switch on the SQL log in .env:
LOG_SQL=trueRestart pnpm dev and ask for something real:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 1) { name motto adventurers { name class level } } }"}'{"data":{"guild":{"name":"Ember Lions","motto":"First into the fire.","adventurers":[{"name":"Brakka","class":"WARRIOR","level":7},{"name":"Ilse","class":"MAGE","level":5},{"name":"Corin","class":"ROGUE","level":3}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 500) { title } }"}'{"errors":[{"message":"first must be between 1 and 100","locations":[{"line":1,"column":3}],"path":["quests"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}
The N+1 problem
Now the quest board, the query a frontend would send first: every quest with its guild and its adventurer.
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests { title status guild { name } assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","guild":{"name":"Ember Lions"},"assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","guild":{"name":"Ember Lions"},"assignee":null},{"title":"Tame the volcano drake","status":"OPEN","guild":{"name":"Ember Lions"},"assignee":null},{"title":"Chart the sunken reef","status":"ACCEPTED","guild":{"name":"Tidecallers"},"assignee":{"name":"Tamsin"}},{"title":"Calm the storm spirit","status":"DONE","guild":{"name":"Tidecallers"},"assignee":{"name":"Nerys"}},{"title":"Recover the pearl crown","status":"OPEN","guild":{"name":"Tidecallers"},"assignee":null},{"title":"Clear the harbour of krakenlings","status":"OPEN","guild":{"name":"Tidecallers"},"assignee":null},{"title":"Guard the seed vault","status":"ACCEPTED","guild":{"name":"Moss Wardens"},"assignee":{"name":"Fen"}},{"title":"Wake the old treant","status":"OPEN","guild":{"name":"Moss Wardens"},"assignee":null}]}}
The answer is right. Look at what the server did for it:
Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" order by "quests"."id" limit $1 -- params: [20]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [5]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [4]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [7]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [3][15:20:11.909] INFO (67): Request processed requestId: "req-3" method: "POST" path: "/graphql" operationType: "query" status: 200 errorCount: 0 durationMs: 14
Sixteen queries for ten quests. One for the list, then one for the guild of every quest, and one more for every quest that has an adventurer. The Ember Lions alone are loaded four times. This is the N+1 problem: one query for the list, and another for each of its N rows.
It grows with every row and every level. Ask for all guilds with their adventurers, the quests of those adventurers, and the quests of the guilds with their adventurers:
{ guilds { name adventurers { name quests { title } } quests { title assignee { name } } } }That takes 21 queries for three guilds. A page of 50 quests needs up to 101, and every one of them is a round trip to the database.
In the next part DataLoader collects those lookups and answers them with one query per table: three queries instead of sixteen, whatever the size of the page.