GraphQL Conventions
A GraphQL API is one shared schema that many clients depend on. The schema is the contract. Design it with care: clear types, safe nullability, consistent naming, sensible pagination, and limits that stop a single query from overloading the server. This is the concrete conventions reference that goes with the broader API & Contract Design guideline.
GraphQL moves a lot of power to the client. One request can ask for many resources at once and follow links between them. That is useful, but it also means a careless query can be slow, expensive, or unsafe. Design the schema and the resolvers so the common case is fast and the worst case is bounded.
All the security rules still apply: authenticate and authorise every field, validate input at the boundary, never return too much data, and never leak internals (see Authentication & Authorization, Trust Boundaries). Authorisation in GraphQL is per field, not per endpoint, so check it in the resolver, not just at the edge.
Schema design
- DoName types in
PascalCaseand fields incamelCase. Use the same name for the same concept everywhere. The schema is the public contract, so design it for the client, not for your database tables. - DoMake a field non-null only when it can never be null. A non-null field that throws turns into a null and can blank out its whole parent object. When unsure, make it nullable.
- DoModel real domain types, not a copy of your database rows. Use enums for fixed sets of values, and input types for mutation arguments. Keep mutation names action-based (
createCustomer,cancelOrder). - AvoidExposing database columns one-for-one, leaking internal ids, or adding generic catch-all fields (a
jsonblob, ametadatabag) that hide the real shape from clients.
Errors & responses
Unlike REST, GraphQL does not use HTTP status codes to report application errors. A request that reaches the server returns HTTP 200 even when the query fails, with the problems listed in the response's errors array. This is how the GraphQL specification works and we cannot change it, so clients must check the errors array, not the HTTP status. (For the REST approach, see HTTP Status Codes.)
- DoUse the standard
errorsarray for failures, with a stable machine-readablecodein each error's extensions (for exampleNOT_FOUND,FORBIDDEN,VALIDATION). Clients should branch on the code, not on the message text. - DoFor expected, business-level outcomes (validation failed, not allowed), consider returning them as part of the result type (a typed result or union) instead of a top-level error. Top-level errors are best for unexpected faults.
- NeverLeak internal details (stack traces, SQL, secrets, file paths) in an error message or extension, or return another tenant's or user's data (see Multi-Tenancy, Error Handling).
- AvoidA single 5xx HTTP status for normal GraphQL responses. A valid GraphQL response is usually HTTP 200 with errors in the body; reserve transport-level status codes for transport-level problems (auth, malformed request).
Performance & pagination
- DoBatch and cache resolver data loads (for example with a per-request DataLoader) to avoid the N+1 problem, where fetching a list then fetching each item one by one floods the database.
- DoPaginate lists with a consistent pattern (cursor-based connections are the common choice) and cap the page size. Never let a client ask for an unbounded list.
- AlwaysLimit query depth and cost. Reject queries that are too deeply nested or too expensive before running them, so one crafted query cannot exhaust the server (see Rate Limiting & Abuse Prevention).
Safety & evolution
- AlwaysAuthenticate the request and authorise every field that exposes protected data, deriving identity and tenant on the server. Do not rely on the client to omit fields it should not see (see Authentication & Authorization, Trust Boundaries).
- DoEvolve the schema by adding fields and types, and mark old ones with
@deprecated(reason: ...)rather than removing or renaming them. Removing or retyping a field is a breaking change (see Backward Compatibility). - DoValidate and bound every input. Apply rate and size limits, and prefer persisted (allow-listed) queries for public or partner clients so only known queries run.
- ConsiderTurning off schema introspection for untrusted clients in production, and never exposing internal-only fields on a schema that partners can reach.
A schema, end to end
type Query { customers: [Customer!]! } # no paging, no limit
type Customer { ssn: String! orders: [Order!]! } # leaks PII, N+1 on orders
# error body: stack trace and SQL
An unbounded list, sensitive data exposed by default, a field that triggers one query per row, and internal detail leaked on failure. Slow, expensive, and unsafe.
type Query { customers(first: Int! = 20, after: String): CustomerConnection! }
type Customer { id: ID! name: String! orders: OrderConnection! } # batched via DataLoader
# errors: { extensions: { code: "FORBIDDEN" } }, no internals
A capped connection, only fields the caller is allowed to see, batched data loads, and a stable error code with no internal detail. Depth and cost are limited before execution.
Self-review checklist
- AskIs every field that exposes protected data authorised in its resolver, with identity and tenant derived on the server?
- AskCould a single query be too deep, too wide, or trigger an N+1, and is that bounded before it runs?
- AskAre list fields paginated and capped, and is each non-null field really never null?
- AskIs this schema change additive, or did I remove or retype a field that clients depend on?