| name | graphql-patterns |
| description | Use when designing, building, or operating GraphQL APIs with Apollo Server + TypeScript — covers schema-first SDL design, resolver architecture, DataLoader, JWT and directive-based authz, Relay cursor pagination, typed error payloads, federation v2, graphql-codegen, and production hardening (depth/complexity limits, timeouts, persisted queries). Load references/graphql-security.md for hostile-input defence. |
| metadata | {"portable":true,"compatible_with":["Codex","codex"]} |
GraphQL Patterns (Apollo + TypeScript)
Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com, +256 784 464178.
Use When
- Designing a new GraphQL API or migrating from REST
- Building Apollo Server + TypeScript with typed resolvers and clients
- Adopting Relay pagination, DataLoader, directive auth, or federation
- Hardening a GraphQL service for production
Do Not Use When
- Pure REST/gRPC — use
api-design-first
- Threat-modelling GraphQL against malicious input — load
references/graphql-security.md first
- Single canonical UI shape, CDN caching dominant — REST is simpler
Required Inputs
Domain entities + UI shapes; auth model (JWT/roles/tenants); federation needs; target clients and pagination style.
Workflow
- Draft SDL against real UI shapes before resolvers.
- Model relationships in the schema, not in resolvers.
- Wire
context (auth + loaders) before the first resolver.
- Add depth + complexity
validationRules before the first public client.
- Add graphql-codegen to CI before the schema has a second consumer.
- Ship with
formatError masking production stack traces.
Quality Standards
- Lists are
[T!]! unless partial failure is legal.
- Every mutation takes
input: XxxInput! and returns a payload type.
- Every resolver is pure: reads
context, calls services/loaders, returns the schema shape.
- Every relationship crossing uses DataLoader — no direct DB calls in nested resolvers.
- Production always has depth limit, complexity limit, error masking.
Anti-Patterns
Monolithic schema.js/index.js; mixed auth patterns across resolvers; client-supplied userId trusted; introspection on with no cost analysis; bare throw new Error; String for dates.
Outputs
schema.graphql + operations + generated TS; Apollo Server bootstrap; per-request DataLoader factory; codegen.yml; security baseline (depth/complexity/introspection/CORS/helmet).
Evidence Produced
| Category | Artifact | Format | Example |
|---|
| Correctness | GraphQL schema decision record | Markdown doc per skill-composition-standards/references/adr-template.md covering SDL design, resolver patterns, and N+1 mitigations | docs/graphql/schema-adr.md |
| Performance | Resolver performance budget | Markdown doc covering per-resolver latency budget and DataLoader strategy | docs/graphql/resolver-budget.md |
References
- Fullstack GraphQL (TS + Apollo + React); JavaScript Everywhere — Adam D. Scott (O'Reilly, 2020).
- Apollo Server + Federation v2 docs; Relay cursor connection spec (
relay.dev/graphql/connections.htm).
- Companion skills:
api-design-first, references/graphql-security.md, typescript-full-stack, nodejs-development.
Overview
GraphQL inverts REST: the client declares the shape, the server resolves. This kills endpoint proliferation at the cost of new problems (N+1, depth attacks, typed error plumbing). This skill prescribes a production-grade Apollo + TypeScript baseline that avoids those pitfalls by construction.
Cardinal rule: The schema is the contract. Design it first; every other layer derives from it.
1. Schema-First Design (SDL)
Order of work: UI shape → SDL types → DB model → resolvers. Never reverse this.
scalar DateTime
type Note {
id: ID!
content: String!
author: User!
createdAt: DateTime!
favoritedBy: [User!]!
}
type User {
id: ID!
username: String!
notes(first: Int = 20, after: String): NoteConnection!
}
input CreateNoteInput { content: String! }
type CreateNotePayload { note: Note, errors: [UserError!]! }
type UserError { field: String, message: String! }
type Query {
me: User
note(id: ID!): Note
}
type Mutation {
createNote(input: CreateNoteInput!): CreateNotePayload!
}
Non-null rules:
Note! — never null. [Note!]! — list never null, no null elements (the default for collection queries).
[Note] only when partial results are legal.
- Nullable-string like
description: String is right when the domain allows absence; everywhere else a null is a latent bug.
Input types are mandatory once a mutation has >2 scalar args. Wrapping in an input lets you add fields later without changing the call site.
Mutation payload types never return the bare entity. Return CreateNotePayload { note, errors } so clients can surface validation errors as typed data (see §6).
Interface vs union:
interface when variants share fields: interface RepositoryOwner { login: String! } → User, Organization both implement.
union when variants share nothing: union SearchResult = Issue | PullRequest | Repository.
Clients discriminate with __typename + inline fragments — always request __typename on interface/union queries or Apollo client cache normalisation breaks.
Custom scalars — never String for dates. Bind graphql-iso-date's GraphQLDateTime to the DateTime scalar. The resolver then receives a real Date, not an unvalidated string.
2. Resolvers
Canonical signature: (parent, args, context, info) — parent from parent resolver, args schema-typed, context per-request (built once), info AST (use for projection/cost, never for routing).
Split resolvers per type. One file per Query.ts, Mutation.ts, Note.ts (relationships), User.ts, scalars.ts.
import { NoteResolvers } from '../__generated__/graphql';
export const Note: NoteResolvers = {
author: (note, _a, { loaders }) => loaders.userById.load(note.author_id),
favoritedBy: (note, _a, { loaders }) => loaders.usersByNoteId.load(note.id),
};
Thin root resolvers, relationship resolvers on the type. Apollo calls relationship resolvers only when the client selects the field — this is what makes "pay only for what you select" real.
3. Context Construction (where auth + loaders live)
import { readJwt } from './auth';
import { makeLoaders } from './loaders';
export type Context = {
user: { id: string; roles: string[] } | null;
loaders: ReturnType<typeof makeLoaders>;
db: Db;
};
export async function buildContext({ req }: { req: IncomingMessage }): Promise<Context> {
const user = readJwt(req.headers.authorization);
return { user, loaders: makeLoaders(db), db };
}
Decode the JWT exactly once, then every resolver reads context.user. Never trust args.userId.
4. N+1 and DataLoader
The problem: notes { author { username } } hits the DB once per note. Logs show SELECT user WHERE id = 1, ... = 2, ... = 2, ... = 3.
The fix: one DataLoader per join, per request.
import DataLoader from 'dataloader';
export function makeLoaders(db: Db) {
return {
userById: new DataLoader<string, User>(async (ids) => {
const rows = await db.users.findByIds([...ids]);
const map = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => map.get(id)!);
}),
usersByNoteId: new DataLoader<string, User[]>(async (noteIds) => {
const rows = await db.noteFavorites.findByNoteIds([...noteIds]);
const grouped = groupBy(rows, 'noteId');
return noteIds.map((id) => grouped[id] ?? []);
}),
};
}
Two rules: (1) the returned array must match the keys array by index; (2) the loader must be built per-request — sharing across requests leaks data between tenants.
Per-request caching is the second win. In circular traversals (notes → author → notes) the Map-backed cache means each userById.load('u1') hits the DB once.
When not to use DataLoader:
- After a mutation that needs fresh data — call
loaders.userById.clear(id) or bypass.
- Non-ID-keyed lookups (filter + sort combinations) — the loader key space becomes combinatorial; prefer a thin service layer.
- Multi-gigabyte value shapes — the
Map blows heap; move caching to Redis.
DataLoader is a per-request optimisation. It does not replace HTTP or Redis caching.
5. Auth and Authz
JWT in the header, decoded once in context. Both web and mobile clients set Authorization: Bearer <token>. Never sessions for a public GraphQL API — they collapse under mobile clients.
Field-level authorisation — two patterns, pick one and hold it.
Code-based:
import { AuthenticationError, ForbiddenError } from 'apollo-server-errors';
export const Mutation: MutationResolvers = {
deleteNote: async (_, { id }, { user, db }) => {
if (!user) throw new AuthenticationError('Session invalid');
const note = await db.notes.byId(id);
if (String(note.author_id) !== user.id) throw new ForbiddenError('Not your note');
await db.notes.delete(id);
return { ok: true };
},
};
Directive-based (declarative):
directive @auth(role: Role) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION
type Mutation {
publishNote(id: ID!, published: Boolean! @auth(role: ADMIN)): Note @auth
}
A custom @auth schema directive wraps defaultFieldResolver, inspects context.user.roles, and throws or proceeds. Put the directive implementation in one file; grep-readers see the rule on the field itself.
Pitfalls:
- Mixing both patterns → some paths are checked twice, some missed.
AuthenticationError (unauthenticated) vs ForbiddenError (authenticated, not allowed) map to distinct UX — clients route on extensions.code.
- Normalise email on signup (
.trim().toLowerCase()). Hash passwords with bcrypt, 10+ salt rounds.
6. Pagination (Relay Cursor Connection)
Offset pagination is simple and scales poorly. Use cursors for production.
type NoteConnection {
edges: [NoteEdge!]!
nodes: [Note!]!
pageInfo: PageInfo!
totalCount: Int
}
type NoteEdge { cursor: String!, node: Note! }
type PageInfo {
startCursor: String
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
type Query { feed(first: Int = 20, after: String): NoteConnection! }
export const Query: QueryResolvers = {
feed: async (_, { first = 20, after }, { db }) => {
const limit = Math.min(first!, 100);
const rows = await db.notes.page({ after, limit: limit + 1 });
const hasNextPage = rows.length > limit;
if (hasNextPage) rows.pop();
return {
edges: rows.map((n) => ({ cursor: encode(n.id), node: n })),
nodes: rows,
pageInfo: {
startCursor: rows.length ? encode(rows[0].id) : null,
endCursor: rows.length ? encode(rows.at(-1)!.id) : null,
hasNextPage,
hasPreviousPage: false,
},
};
},
};
Cursors are opaque — base64-encode them so clients do not parse IDs and so you can change the underlying ordering without breaking anyone. totalCount is expensive on large tables — make it nullable and only compute when asked.
7. Error Handling
Apollo ships typed error classes: AuthenticationError, ForbiddenError, UserInputError, ApolloError. Each sets extensions.code to a stable machine-readable string (UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT). Clients route on code, not on message.
import { UserInputError } from 'apollo-server-errors';
if (!email.includes('@')) throw new UserInputError('Invalid email', { field: 'email' });
Prefer errors-as-data for field-level validation. Return them in the payload type so the UI renders per-field messages without reading the top-level errors[]:
type CreateNotePayload { note: Note, errors: [UserError!]! }
type UserError { field: String, message: String!, code: String! }
Mask in production. Stack traces and upstream messages leak internals. Use formatError:
new ApolloServer({
formatError: (err) => {
logger.error(err);
if (err.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new Error('Internal server error');
}
return err;
},
});
8. Apollo Server Setup
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginLandingPageProductionDefault } from '@apollo/server/plugin/landingPage/default';
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer<Context>({
typeDefs, resolvers,
introspection: process.env.NODE_ENV !== 'production',
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000, { onCost: (c) => metrics.queryCost.observe(c) }),
],
plugins: [ApolloServerPluginLandingPageProductionDefault()],
formatError,
});
await server.start();
app.use('/graphql', helmet(), cors(corsOpts), express.json(), expressMiddleware(server, { context: buildContext }));
Middleware order matters: helmet → cors → json → Apollo. Validation rules run before execution — that is the correct layer for depth and complexity guards, not resolvers.
9. Subscriptions (graphql-ws)
Transport is graphql-ws (not deprecated subscriptions-transport-ws). Auth the connection_init frame, not every message.
useServer({
schema,
context: async (ctx) => {
const user = readJwt(`Bearer ${ctx.connectionParams?.authToken}`);
if (!user) throw new Error('unauthenticated');
return { user, loaders: makeLoaders(db), db };
},
}, new WebSocketServer({ server: httpServer, path: '/graphql' }));
Subscription: {
noteAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator('NOTE_ADDED'),
(payload, _a, ctx) => payload.noteAdded.tenant_id === ctx.user.tenant_id,
),
},
},
In a multi-node deploy use Redis-backed PubSub (graphql-redis-subscriptions) so events fan out across instances.
10. Federation v2
Apollo Federation v2 composes multiple subgraph schemas into one supergraph served by a router. Each team owns a subgraph.
type User @key(fields: "id") {
id: ID!
username: String!
}
type Note @key(fields: "id") {
id: ID!
content: String!
author: User!
}
type User @key(fields: "id") {
id: ID! @external
notes: [Note!]!
}
Directives to know:
@key(fields: "...") — identifies the entity; the router uses it to stitch.
@external — a field is declared here but owned elsewhere.
@shareable — the field can be resolved by multiple subgraphs (router picks one).
@requires(fields: "...") — this resolver needs fields from another subgraph before it can run.
@provides(fields: "...") — this resolver already returns fields from another subgraph, no need to fetch.
Entity resolution happens in the subgraph that owns the @key:
Query: {
_entities: (_, { representations }, { loaders }) =>
representations.map((rep) => loaders.userById.load(rep.id)),
},
Composition runs in CI (rover supergraph compose); the router (Apollo Router or @apollo/gateway) serves the composed schema. Do not write supergraph schemas by hand.
11. TypeScript End-to-End (graphql-codegen)
schema: src/schema.graphql
documents: 'src/**/*.graphql'
generates:
src/__generated__/graphql.ts:
plugins: [typescript, typescript-operations, typescript-resolvers]
config: { contextType: '../context#Context', useIndexSignature: true }
typescript generates schema types. typescript-operations generates one type per query matching exactly the fields that query selects — the anti-hallucination safeguard. typescript-resolvers types the resolver maps against the schema. Add typescript-react-apollo on the client for typed useFeedQuery() hooks.
Run yarn generate on every schema/operation change; CI fails if generated files drift. Without this discipline, TypeScript silently lies.
12. File Uploads
Apollo Server 4 does not ship uploads — use graphql-upload-minimal with the multipart request spec.
app.use('/graphql', graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 3 }));
Resolver receives { createReadStream, filename, mimetype }. Stream directly to S3 — never buffer the full file. Enforce size at middleware and reverse proxy. If binary traffic is heavy, keep a plain HTTP POST endpoint and let the mutation return only a reference.
13. Testing
Unit: resolvers are just functions — call them directly with a faked context. await Query.feed!({}, { first: 5 }, ctx as any, {} as any).
Integration: @apollo/server exposes server.executeOperation({ query, variables }, { contextValue }) to run the pipeline in-process — preferred over hitting HTTP.
Mocked schema via addMocksToSchema (@graphql-tools/mock) for frontend dev without a backend.
Schema snapshot tests — printSchema(schema) → golden file. Any accidental breaking change fails CI before a resolver runs.
14. Production Hardening
Mandatory for any public endpoint: depth limit (depthLimit(7)); complexity/cost analysis (graphql-cost-analysis — cap per request, record score for rate-limiting and metering); introspection off in production (GitLab's 2019 DoS was unlimited introspection); persisted queries (APQ + whitelist — reject unknown hashes in strict mode); timeouts (HTTP 30 s at proxy, resolver AbortController on downstream); disable batching array requests unless used (bypasses per-request rate limits); CORS allow-list — never *; helmet + TLS at the edge; tracing (OpenTelemetry → Jaeger/Tempo + Apollo Studio).
Load references/graphql-security.md for the adversarial checklist (alias/directive overloading, SSRF via argument fields, CSRF on mutations).
15. When Not to Use GraphQL
- Single canonical client, HTTP caching dominates, no N+1 pain → REST is cheaper.
- Heavy binary uploads or server-sent file streams → HTTP native.
- Public API where CDN edge caching is business-critical → GraphQL's POST-only default defeats it.
- One team, one service, <10 endpoints → the tooling weight does not pay back.
Hybrid default: GraphQL as a BFF over REST microservices — REST for webhooks and file upload, GraphQL for client-shaped reads of relational UIs. Pairs naturally with microservices-architecture, which owns the inter-service communication patterns.