Skip to content

GraphQL Schema Design Rules

Cursor.cursorrulesCoding

Schema-first GraphQL rules with a strict nullability policy, Relay-style connections, typed error unions, and naming conventions.

Use when: Use when designing or extending a GraphQL schema to keep nullability, pagination, mutations, and error handling consistent across the graph.

@library0 upvotes0 remixes0 comments

Get this skill

Add the skill file with the CLI, by hand, or over MCP.

Save this to your project or home directory so Cursor can load it.

npx goodprompts add graphql-schema-cursorrules
Saves to./.cursorrules
npx goodprompts add graphql-schema-cursorrules
Saves to ./.cursorrules

Skill file

.cursorrules3,551 characters
You are designing and evolving a GraphQL schema. The schema is a public contract: every decision below exists because changing it later breaks clients.
## Nullability policy
- Default to non-null (`!`) for scalars and enums on object types. A field is nullable only when one of these is true: the value is genuinely optional in the domain, it is resolved from a separate service that can fail independently, or it is being deprecated.
- List fields are `[Thing!]!` — non-null list of non-null items. Empty list means "none"; `null` lists and `null` holes inside lists are banned.
- Top-level lookup fields that can miss return nullable: `user(id: ID!): User` (null = not found). Do not throw for not-found on lookups.
- Nullability is the error boundary: a non-null field that errors nulls its whole parent selection. Anything backed by a flaky downstream gets nullable so one failure cannot blank an entire response.
- Input fields: required (`!`) only when the operation cannot proceed without them. In update inputs every field is optional — absent means "leave unchanged".
## Pagination: connections only
- Every list that can grow unbounded uses Relay-style connections: `invoices(first: Int!, after: String): InvoiceConnection!` with `edges { cursor node }`, `pageInfo { hasNextPage endCursor }`, and `totalCount: Int!` where counting is cheap.
- Cursors are opaque base64 strings encoding sort key + id — never raw offsets, never a bare id the client could fabricate.
- Plain lists (`[Tag!]!`) are allowed only for small bounded sets (≤ ~50, e.g. tags on one record, enum-like reference data). If you cannot state the bound, it's a connection.
- Cap `first` at 100 in the resolver and document the cap in the field description.
## Errors as unions, not exceptions
- Domain failures are data. Mutations return a union: `union CreateInvoiceResult = CreateInvoicePayload | ValidationError | DuplicateInvoiceError`, so clients must handle each case in the selection set.
- Error types implement a shared interface: `interface UserError { message: String! code: String! }`, with `field: [String!]` on validation errors pointing at the offending input path.
- The top-level GraphQL `errors` array is reserved for exceptional failures only: unauthenticated, internal errors, malformed queries. Business outcomes ("name taken", "insufficient balance") never go there.
- No `success: Boolean` flags and no stringly-typed `errorCode` fields on payloads — the union member IS the outcome.
## Mutations
- Named `verbNoun`: `createInvoice`, `archiveProject`, `addCartItem`. One mutation per user intent — no generic `updateEntity(json: String!)`.
- Exactly one argument: `input: CreateInvoiceInput!`. Payload types are mutation-specific (`CreateInvoicePayload`) and return the mutated entity plus anything the client must refetch (e.g. the parent whose counts changed).
## Naming and evolution
- Types `PascalCase`; fields/arguments `camelCase`; enum values `SCREAMING_SNAKE_CASE`. Enums over booleans whenever a third state is conceivable (`status: InvoiceStatus!`, not `isPaid: Boolean!`).
- No CRUD-leak names: the schema models the domain (`Customer.outstandingBalance`), not tables (`Customer.invoice_rows`).
- Never remove or retype a published field. Deprecate first — `@deprecated(reason: "Use totals.gross. Removal after 2026-12-01.")` — and only remove after usage telemetry reads zero.
- Every type and non-obvious field carries a description block (`"""..."""`). IDs are the `ID` type, globally unique, never exposed database integers.
Comments

Comments

Sign in to commentSign in

Remixes

Loading remixes…