GraphQL Cheat Sheet
Covers GraphQL schema definition, queries, mutations, resolvers, and common patterns like fragments and batching for flexible APIs.
Schema Definition Language
Defining types, queries, mutations, and subscriptions.
type Book { id: ID! title: String! author: Author! publishedYear: Int}type Author { id: ID! name: String! books: [Book!]!}type Query { books: [Book!]! book(id: ID!): Book}type Mutation { addBook(title: String!, authorId: ID!): Book!}type Subscription { bookAdded: Book!}
Queries & Mutations
Fetching and writing data with variables, aliases, and fragments.
# Query with variables and nested field selectionquery GetBook($id: ID!) { book(id: $id) { title author { name } }}# Mutationmutation AddNewBook { addBook(title: "Dune", authorId: "42") { id title }}# Aliases and fragmentsquery { dune: book(id: "1") { ...BookFields } hobbit: book(id: "2") { ...BookFields }}fragment BookFields on Book { title publishedYear}
Resolver Example
Apollo Server-style resolver map, including a nested field resolver.
const resolvers = { Query: { books: (parent, args, context) => context.db.books.findAll(), book: (parent, { id }, context) => context.db.books.findById(id), }, Mutation: { addBook: (parent, { title, authorId }, context) => context.db.books.create({ title, authorId }), }, Book: { // Nested resolver to resolve the author field author: (book, args, context) => context.db.authors.findById(book.authorId), },};
Core Concepts
Vocabulary every GraphQL API relies on.
- Query- Read-only operation; client specifies the exact shape of the response
- Mutation- Operation that writes/modifies data on the server
- Subscription- Long-lived operation that streams updates, typically over WebSockets
- Resolver- Function that returns the data for one specific field in the schema
- Schema- Strongly-typed contract (SDL) defining every possible query, mutation, and type
- N+1 problem- Naive resolvers issuing one DB query per item; fixed with batching (DataLoader)
- Introspection- Ability to query the schema itself, used by tools like GraphiQL
DataLoader: Batching & Per-Request Caching
Collapsing N individual lookups into a single batched call within one event-loop tick.
const DataLoader = require('dataloader');function createLoaders(db) { const authorLoader = new DataLoader(async (authorIds) => { const authors = await db.authors.findByIds(authorIds); const byId = new Map(authors.map((a) => [a.id, a])); // Must return results in the SAME ORDER as the input keys return authorIds.map((id) => byId.get(id) ?? null); }); return { authorLoader };}// Per-request instantiation avoids leaking cached data across usersconst resolvers = { Book: { author: (book, args, context) => context.loaders.authorLoader.load(book.authorId), },};// context factory, e.g. in Apollo Serverconst server = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => ({ loaders: createLoaders(db), user: req.user }),});
Cursor-Based (Relay-style) Pagination
Connection/edge schema shape for stable pagination over changing data.
type BookConnection { edges: [BookEdge!]! pageInfo: PageInfo!}type BookEdge { cursor: String! node: Book!}type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String}type Query { books(first: Int, after: String): BookConnection!}# Client usagequery { books(first: 10, after: "YXJyYXljb25uZWN0aW9uOjk=") { edges { cursor node { id title } } pageInfo { hasNextPage endCursor } }}
Custom Directive: @auth
Enforcing role-based access declaratively in the schema instead of inside every resolver.
// SDL: directive @auth(requires: Role = USER) on FIELD_DEFINITION// enum Role { USER ADMIN }const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');function authDirectiveTransformer(schema) { return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0]; if (!authDirective) return fieldConfig; const { requires } = authDirective; const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = (source, args, context, info) => { if (!context.user || context.user.role !== requires) { throw new Error('Not authorized'); } return resolve(source, args, context, info); }; return fieldConfig; }, });}
Structured Errors with extensions
Returning machine-readable error codes alongside the human-readable message.
const { GraphQLError } = require('graphql');function notFoundError(entity, id) { return new GraphQLError(`${entity} ${id} not found`, { extensions: { code: 'NOT_FOUND', http: { status: 404 } }, });}const resolvers = { Query: { book: async (parent, { id }, context) => { const book = await context.db.books.findById(id); if (!book) throw notFoundError('Book', id); return book; }, },};// Client-side check// if (error.extensions?.code === 'NOT_FOUND') { ... }
Production Security Hardening
Defenses against the abuse patterns unique to a flexible query language.
- Query depth limiting- reject deeply nested queries that could cause exponential resolver fan-out
- Query cost analysis- assign a cost per field/connection and reject queries exceeding a budget, not just a depth count
- Disable introspection in prod- prevents attackers from enumerating your full schema (types, mutations, deprecated fields)
- Persisted queries- clients send a hash instead of raw query text; server only executes pre-registered operations
- Rate limiting per field- throttle expensive fields (e.g. search, aggregate) independently of the overall request rate
- Timeouts- cap resolver execution time so one slow field can't hold a request (and a connection pool slot) open indefinitely
Use DataLoader (or an equivalent per-request batching cache) inside resolvers to avoid the N+1 query problem — without it, fetching 100 books' authors triggers 100 separate database round-trips.