name: designing-graphql-apis description: Designs GraphQL APIs with schema-first typing, Relay-style cursor connections, DataLoader batching against N+1 queries, union result types for expected errors, and depth/complexity limits. Use when the user asks to design, review, or refactor a GraphQL schema or API, write type definitions or resolvers, add pagination to a GraphQL query, fix N+1 resolver performance, or structure mutations. Do not use for REST APIs (designing-rest-apis) or for real-time subscription transport infrastructure.
Designing GraphQL APIs
When to use / when NOT to use
- Use for: GraphQL schema design, type/resolver structure, connections, mutations, error modeling, query cost control.
- Do NOT use for: REST endpoint design →
designing-rest-apis; WebSocket/subscription transport setup; client-side query writing.
Core rules
- Schema first, domain-shaped. Design the SDL from the domain and client needs, then implement resolvers. Never mirror database tables 1:1 — expose what clients consume.
- ✅
type Order { total: Money!, placedAt: DateTime! } - ❌
type OrdersTbl { amt_cents: Int, created_ts: Int }
- ✅
- Nullable by default; non-null (
!) only when guaranteed. A non-null field that fails nulls out its whole parent chain. Reserve!for IDs and fields the resolver can never fail to produce. - Paginate lists with Relay connections. Any list that can grow gets
Connection/Edge/PageInfowithfirst/aftercursor args — never a bare unbounded[Order!]!. - DataLoader is the default N+1 fix. Every resolver that fetches by ID goes through a per-request DataLoader that batches and caches. One query per collection of parents, not one per parent.
- Mutations: verb-object names, one input, one payload.
createOrder(input: CreateOrderInput!): CreateOrderPayload!. Payload contains the changed object plus expected-error fields — never return the bare object. - Expected errors are union result types; the
errorsarray is for exceptions only.- ✅
union CreateOrderResult = Order | ValidationError | OutOfStockError - ❌ throwing for "email already taken" so clients parse
errors[0].message
- ✅
- Cap query cost. Enforce a depth limit (default 10) and a complexity budget (points per field × list multipliers); reject over-budget queries before execution.
- Additive evolution only. Add fields freely; never remove or change a type in place —
@deprecated(reason: "...")first, remove in a coordinated major cycle.
Workflow
- List client use cases; sketch queries clients should be able to write.
- Write the SDL: types, connections (rule 3), mutations (rule 5), error unions (rule 6).
- Apply nullability discipline (rule 2) type by type.
- Plan resolvers: mark every by-ID fetch as a DataLoader (rule 4).
- Set depth/complexity limits (rule 7).
- Validate: run the schema through a linter (
npx graphql-schema-linter schema.graphql— install withnpm i -g graphql-schema-linterif missing), execute the sketched queries from step 1 against a stub server, and log SQL for one nested query to confirm no N+1 (query count must be O(depth), not O(rows)). Fix and repeat.
Edge cases & failure modes
- A field that is expensive for some parents → split it (
Order.summarycheap,Order.analyticsseparate type) so cheap queries don't pay. - Polymorphic lists → interfaces when types share fields, unions when they don't; always include
__typenamehandling in examples. - File uploads → do not tunnel through GraphQL; issue a presigned URL via mutation and upload out-of-band.
- Global object identity → give every fetchable type a globally unique
id: ID!(base64Type:dbId) so caches andnode(id:)refetching work.
References
Deeper patterns (connection SDL, DataLoader implementation, complexity scoring): see references/patterns.md.