GraphQL Standards
Source: GraphQL Best Practices · Apollo Naming Conventions · GraphQL Schema Design
Priority: P0 — Schema Correctness
Nullability
The GraphQL spec makes every field nullable by default. A wrong ! is not just inaccurate — when a non-null field resolver returns null, GraphQL propagates the null upward through the response tree, wiping out the nearest nullable parent. One bad guarantee can null out an entire object from the client's response (graphql.org).
- Mark a field non-null (
!) only when the server can guarantee it will never be null — not just "usually non-null".
- Mutation payload root fields (
user, order) must be nullable — the mutation may fail and return errors instead of data.
- List elements and the list itself have separate nullability:
[User!]! means a non-null list of non-null users.
ID! is always non-null — if you have an ID, it exists.
Scalars & Custom Types
- Use built-in scalars:
String, Int, Float, Boolean, ID.
- Add custom scalars for domain types:
DateTime, Date, URL, EmailAddress, JSON.
DON'T use String for dates, emails, or URLs — custom scalars carry semantic meaning, enable server-side validation, and surface intent in generated client types.
See refs/schema-design.md for custom scalar declaration examples.
Mutation Shape
Every mutation must take a single input argument typed as an Input object and return a Payload type — never a naked entity, never Boolean (Apollo).
See refs/schema-design.md for input/payload design rationale and full examples.
Global Object Identification
Implement the Node interface (graphql.org) for any type that clients need to refetch individually. The id field must be globally unique across all types — base64-encode the type and database ID (base64("User:123")), never expose raw database IDs. Without this, Apollo Client and Relay cannot correctly normalise their caches.
No Breaking Changes
GraphQL is designed to be versionless — evolve the schema forward additively. Removing or renaming a field breaks every client using it with no warning (graphql.org).
- Never remove a field without first marking it
@deprecated and confirming zero usage via field-level observability.
- Never rename a field — add the new name, deprecate the old one, remove only after migration.
- Never make a nullable field non-null — existing clients don't send the value; they will fail validation.
- Never add a required argument to an existing field — existing queries don't include it.
- Adding new nullable fields, new types, and new enum values are safe.
See refs/schema-design.md for the full breaking vs non-breaking change table and deprecation workflow.
Priority: P0 — Security & Performance
Query Depth & Complexity Limits
Without execution limits, a single deeply nested or deliberately wide query can exhaust the database and crash the server. Enforce both limits before execution as validation rules — rejected queries never touch the data layer (graphql.org).
- Depth limit of 5–7 covers most real-world query patterns.
- Apply a stricter limit (e.g., 3) to nested list fields — list nesting causes data to grow exponentially.
- Cost analysis catches shallow-but-wide queries that depth limiting misses.
See refs/security.md for validation-rule setup, rate limiting, introspection, persisted queries, and field-level security.
Authorization
Authorization belongs in the business logic layer, not in resolvers. Scattering if (!context.user) checks across individual resolvers guarantees that some field will be missed (graphql.org).
- Pass a fully hydrated
user object in context, not a raw token.
- Return
null for not-found or unauthorised resources — never throw a permission error that reveals whether the resource exists (existence leakage is an information disclosure).
- For field-level authorization, use a schema transformer or plugin (
@auth directive) that wraps resolvers — never manual if checks per field.
DataLoader (N+1 Prevention)
Every field resolver that fetches by a foreign key must use DataLoader. Without it, a query for 100 posts and their authors fires 101 database queries. At scale this collapses response times and saturates connection pools.
Critical rule: create a new DataLoader instance per request. A shared loader caches data across requests — it will serve one user's data to another (graphql-js.org).
- Batch functions must return results in the same order as input keys — DataLoader maps
result[i] to keys[i]; wrong order silently corrupts data.
- Return
null for missing entries, never a shorter array.
- Use
.prime(key, value) to pre-populate the cache when you already fetched the data in a list query.
See refs/performance.md for the full DataLoader setup, HTTP caching, and query execution patterns.
Anti-Patterns
- Inline scalar arguments on mutations instead of a single
Input type
- Returning a naked entity or
Boolean from a mutation instead of a Payload type
- Using
String for dates, emails, or URLs — use custom scalars
- Non-null (
!) on fields that could realistically be null
- Nullable list elements
[User] — almost always want [User!]!
data, result, response as field names — too generic
- Authorization checks (
if (!context.user)) inside individual resolvers
- Field resolvers that fetch related entities with direct DB calls instead of DataLoader
- Sharing a DataLoader instance across requests — leaks data between users
- Removing or renaming fields without first adding
@deprecated
- Making a nullable field non-null or adding a required argument — breaking changes
- No query depth or complexity limits on a publicly accessible endpoint
- Introspection enabled in production for first-party-only APIs
type or I prefixes on types and interfaces
References
Load only what the current task requires:
- schema-design — designing mutations, custom scalars, global object identification, pagination, error handling patterns, or evolving an existing schema
- conventions — naming conventions, query/subscription shape, interfaces and unions, SDL descriptions, and resolver thinness
- security — server configuration, auth middleware, depth/complexity limits, introspection, rate limiting, or field-level security
- performance — DataLoader setup, N+1 investigation, HTTP caching, or query execution optimisation
- testing — writing or reviewing resolver tests, schema snapshot tests, or integration tests
- tooling — setting up graphql-codegen, graphql-eslint, schema registry, or CI schema checks
1---2name: graphql3description: GraphQL schema design standards and resolver conventions. Use when writing or reviewing any GraphQL schema, resolver, or operation — naming, nullability, types, input objects, mutations, queries, and operation structure.4---56# GraphQL Standards78> Source: [GraphQL Best Practices](https://graphql.org/learn/best-practices/) · [Apollo Naming Conventions](https://www.apollographql.com/docs/graphos/schema-design/guides/naming-conventions) · [GraphQL Schema Design](https://graphql.org/learn/schema-design/)910## Priority: P0 — Schema Correctness1112### Nullability1314The GraphQL spec makes every field nullable by default. A wrong `!` is not just inaccurate — when a non-null field resolver returns `null`, GraphQL propagates the null **upward** through the response tree, wiping out the nearest nullable parent. One bad guarantee can null out an entire object from the client's response ([graphql.org](https://graphql.org/learn/schema-design/#nullability)).1516- Mark a field non-null (`!`) only when the server can **guarantee** it will never be null — not just "usually non-null".17- Mutation payload root fields (`user`, `order`) must be nullable — the mutation may fail and return errors instead of data.18- List elements and the list itself have separate nullability: `[User!]!` means a non-null list of non-null users.19- `ID!` is always non-null — if you have an ID, it exists.2021### Scalars & Custom Types2223- Use built-in scalars: `String`, `Int`, `Float`, `Boolean`, `ID`.24- Add custom scalars for domain types: `DateTime`, `Date`, `URL`, `EmailAddress`, `JSON`.25- `DON'T` use `String` for dates, emails, or URLs — custom scalars carry semantic meaning, enable server-side validation, and surface intent in generated client types.2627See `refs/schema-design.md` for custom scalar declaration examples.2829### Mutation Shape3031Every mutation must take a **single `input` argument** typed as an Input object and return a **`Payload` type** — never a naked entity, never `Boolean` ([Apollo](https://www.apollographql.com/docs/graphos/schema-design/guides/naming-conventions)).3233See `refs/schema-design.md` for input/payload design rationale and full examples.3435### Global Object Identification3637Implement the `Node` interface ([graphql.org](https://graphql.org/learn/global-object-identification/)) for any type that clients need to refetch individually. The `id` field must be **globally unique across all types** — base64-encode the type and database ID (`base64("User:123")`), never expose raw database IDs. Without this, Apollo Client and Relay cannot correctly normalise their caches.3839### No Breaking Changes4041GraphQL is designed to be versionless — evolve the schema forward additively. Removing or renaming a field breaks every client using it with no warning ([graphql.org](https://graphql.org/learn/best-practices/#versioning)).4243- **Never remove a field** without first marking it `@deprecated` and confirming zero usage via field-level observability.44- **Never rename a field** — add the new name, deprecate the old one, remove only after migration.45- **Never make a nullable field non-null** — existing clients don't send the value; they will fail validation.46- **Never add a required argument** to an existing field — existing queries don't include it.47- Adding new nullable fields, new types, and new enum values are safe.4849See `refs/schema-design.md` for the full breaking vs non-breaking change table and deprecation workflow.5051---5253## Priority: P0 — Security & Performance5455### Query Depth & Complexity Limits5657Without execution limits, a single deeply nested or deliberately wide query can exhaust the database and crash the server. Enforce both limits **before execution** as validation rules — rejected queries never touch the data layer ([graphql.org](https://graphql.org/learn/security/)).5859- Depth limit of **5–7** covers most real-world query patterns.60- Apply a stricter limit (e.g., 3) to nested list fields — list nesting causes data to grow exponentially.61- Cost analysis catches shallow-but-wide queries that depth limiting misses.6263See `refs/security.md` for validation-rule setup, rate limiting, introspection, persisted queries, and field-level security.6465### Authorization6667**Authorization belongs in the business logic layer, not in resolvers.** Scattering `if (!context.user)` checks across individual resolvers guarantees that some field will be missed ([graphql.org](https://graphql.org/learn/authorization/)).6869- Pass a fully hydrated `user` object in `context`, not a raw token.70- Return `null` for not-found or unauthorised resources — never throw a permission error that reveals whether the resource exists (existence leakage is an information disclosure).71- For field-level authorization, use a schema transformer or plugin (`@auth` directive) that wraps resolvers — never manual `if` checks per field.7273### DataLoader (N+1 Prevention)7475Every field resolver that fetches by a foreign key **must** use DataLoader. Without it, a query for 100 posts and their authors fires 101 database queries. At scale this collapses response times and saturates connection pools.7677**Critical rule:** create a **new DataLoader instance per request**. A shared loader caches data across requests — it will serve one user's data to another ([graphql-js.org](https://www.graphql-js.org/docs/n1-dataloader/)).7879- Batch functions **must return results in the same order as input keys** — DataLoader maps `result[i]` to `keys[i]`; wrong order silently corrupts data.80- Return `null` for missing entries, never a shorter array.81- Use `.prime(key, value)` to pre-populate the cache when you already fetched the data in a list query.8283See `refs/performance.md` for the full DataLoader setup, HTTP caching, and query execution patterns.8485---8687## Anti-Patterns8889- Inline scalar arguments on mutations instead of a single `Input` type90- Returning a naked entity or `Boolean` from a mutation instead of a `Payload` type91- Using `String` for dates, emails, or URLs — use custom scalars92- Non-null (`!`) on fields that could realistically be null93- Nullable list elements `[User]` — almost always want `[User!]!`94- `data`, `result`, `response` as field names — too generic95- Authorization checks (`if (!context.user)`) inside individual resolvers96- Field resolvers that fetch related entities with direct DB calls instead of DataLoader97- Sharing a DataLoader instance across requests — leaks data between users98- Removing or renaming fields without first adding `@deprecated`99- Making a nullable field non-null or adding a required argument — breaking changes100- No query depth or complexity limits on a publicly accessible endpoint101- Introspection enabled in production for first-party-only APIs102- `type` or `I` prefixes on types and interfaces103104---105106## References107108Load only what the current task requires:109110- [schema-design](refs/schema-design.md) — designing mutations, custom scalars, global object identification, pagination, error handling patterns, or evolving an existing schema111- [conventions](refs/conventions.md) — naming conventions, query/subscription shape, interfaces and unions, SDL descriptions, and resolver thinness112- [security](refs/security.md) — server configuration, auth middleware, depth/complexity limits, introspection, rate limiting, or field-level security113- [performance](refs/performance.md) — DataLoader setup, N+1 investigation, HTTP caching, or query execution optimisation114- [testing](refs/testing.md) — writing or reviewing resolver tests, schema snapshot tests, or integration tests115- [tooling](refs/tooling.md) — setting up graphql-codegen, graphql-eslint, schema registry, or CI schema checks