GraphQL Schema Design skill for Claude Code

Client-shaped schemas that resolve in bounded queries. Claude designs GraphQL types from the queries clients actually send, chooses nullability that will not wipe a whole response on one failed field, and routes every relation through per-request DataLoaders so list queries stop fanning out into hundreds of SQL calls. It adds depth and cost limits, trusted documents, and schema diffs before anything is removed.

  • namegraphql-schema
  • size188 lines · ≈2,050 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and hardens GraphQL APIs - schema-first types, nullability, input and payload types, Relay connections, typed mutation errors, DataLoader batching to kill N+1 resolvers, field-level authorization, depth and cost limits, trusted documents, and breaking-change checks. Use when writing or reviewing a .graphql schema or resolvers, when a GraphQL query is slow or fans out into many SQL calls, or when exposing a GraphQL endpoint publicly.

Try asking

  • Our orders query runs 300 SQL statements. Fix the resolvers
  • Design the GraphQL schema for checkout, including mutation errors
  • Lock down our public GraphQL endpoint against expensive queries

Use it when

  • Writing or reviewing a GraphQL SDL file or code-first schema
  • A GraphQL query is slow or triggers N+1 database calls
  • Exposing a GraphQL endpoint to public or third-party clients
  • Removing or changing fields that existing clients may use

Skip it when

How to check the result

SQL logs show a constant query count as list size grows, graphql-inspector diff reports no unintended breaks, and over-limit queries are rejected before execution.

The common mistake it prevents

Marking relation fields non-null, so a single failed lookup nulls the parent and erases unrelated data from the response.

~/.claude/skills/graphql-schema/SKILL.md

--- name: graphql-schema description: Designs and hardens GraphQL APIs - schema-first types, nullability, input and payload types, Relay connections, typed mutation errors,… license: MIT ---

A good GraphQL API is shaped by client use cases, not database tables; resolves any query in a bounded number of data-source calls; authorizes every field that exposes data; and evolves without versions by adding fields and deprecating old ones.

Workflow

- [ ] 1. Read the current schema, server library, and how context is built per request
- [ ] 2. Design types from client queries (write the queries first)
- [ ] 3. Decide nullability, IDs, pagination, and error modeling
- [ ] 4. Implement resolvers through a per-request DataLoader layer
- [ ] 5. Put authorization in the domain layer, called from resolvers
- [ ] 6. Add depth/complexity limits and trusted documents for public clients
- [ ] 7. Diff the schema for breaking changes; test resolvers with real queries

1. Inspect first

Identify: server (Apollo Server 4/5, GraphQL Yoga, Mercurius, Strawberry, graphql-ruby, gqlgen, Hot Chocolate), schema-first vs code-first, where context is created, whether DataLoader exists, and whether a federation gateway sits in front. Print the SDL:

npx @graphql-inspector/cli introspect http://localhost:4000/graphql --write schema.graphql

2. Types from use cases

Write the two or three queries the UI needs, then design types to serve them.

  • Name types as domain concepts (Order, LineItem), not tables (order_rows).
  • Every entity implements Node { id: ID! } with globally unique opaque IDs if clients cache by ID (Relay, Apollo normalized cache).
  • Do not expose foreign keys (customerId: ID) when a relation field (customer: Customer) serves the client; expose both only if clients genuinely need the raw ID.
  • Use enums for closed sets; document that new values may be added.
  • Custom scalars for DateTime (RFC 3339), Money/Decimal (string), URL, Email.

3. Nullability, pagination, errors

Nullability rule: make a field non-null (!) only when it can never fail to resolve. A non-null field that errors nulls its parent, cascading up to the nearest nullable ancestor, possibly wiping the whole response. Defaults:

Field kindNullability
id, scalar columns on the same rowNon-null
Relations resolved from another service or tableNullable
Lists[Item!]! (list and items non-null; empty list, not null)
ArgumentsNon-null for required, nullable with default for optional

Pagination: Relay connections for any list that can grow:

type Query {
  orders(first: Int = 20, after: String, filter: OrderFilter): OrderConnection!
}
type OrderConnection { edges: [OrderEdge!]!  pageInfo: PageInfo!  totalCount: Int }
type OrderEdge { node: Order!  cursor: String! }
type PageInfo { hasNextPage: Boolean!  hasPreviousPage: Boolean!  startCursor: String  endCursor: String }

Cap first (e.g. 100) in the resolver. Make totalCount nullable and lazy; it is often the slowest part of the query.

Mutations: one input type and one payload type per mutation, with expected failures modeled in the schema rather than in the top-level errors array:

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
input CreateOrderInput { items: [LineItemInput!]!  couponCode: String }
type CreateOrderPayload { order: Order  userErrors: [UserError!]! }
type UserError { field: [String!]  code: UserErrorCode!  message: String! }

Top-level errors are for unexpected failures (bugs, timeouts, auth). Business outcomes (invalid coupon, out of stock) go in userErrors or a result union (union CreateOrderResult = CreateOrderSuccess | OutOfStock | InvalidCoupon). Pick one style for the whole schema.

4. Resolvers and DataLoader

Every relation resolver goes through a DataLoader created per request in context:

import DataLoader from "dataloader";

export function createLoaders(db: Db) {
  return {
    customerById: new DataLoader<string, Customer | null>(async (ids) => {
      const rows = await db.customer.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? null); // same order and length as ids
    }),
  };
}

// context factory: new loaders per request, never module-level (cache leaks across users)
const context = async ({ req }) => ({ user: await auth(req), loaders: createLoaders(db) });

const resolvers = {
  Order: { customer: (order, _args, ctx) => ctx.loaders.customerById.load(order.customerId) },
};

Rules: the batch function returns results in the same order and length as keys; return null or an Error per missing key; one-to-many loaders return arrays per key. For lists of lists (orders then items), key the loader by parent ID and group in memory.

Verify N+1 is gone by logging SQL per request and running a list query: query count must stay constant as first grows.

5. Authorization

  • Authenticate once when building context. Authorize in the business/domain layer that resolvers call, so REST, jobs and GraphQL share the same rules.
  • Check object-level access in the loader or service (order.tenantId === ctx.user.tenantId), not only at the root query: nested fields (customer.orders) are alternate entry points.
  • Hide fields by returning null plus an error, or by schema contracts per audience; do not rely on clients not asking.

6. Protect the endpoint

ThreatControl
Deep nesting (a { b { a { b ... } } })Depth limit (e.g. 10) via validation rule
Huge fan-out (first: 1000 nested twice)Cost/complexity analysis with per-field weights, max cost per request
Arbitrary queries from public clientsTrusted documents (persisted operations): clients send a hash, server rejects unknown hashes in production
Batching abuse (100 operations per HTTP request)Limit operations per request; limit aliases
Schema discoveryDisable introspection in production for private APIs; it is not a security boundary, just noise reduction
Slow resolversTimeouts on data-source calls; tracing per field

Automatic persisted queries (APQ) are a caching optimization, not an allowlist: unknown hashes get registered on demand. Use trusted documents generated at client build time to lock down a first-party API.

7. Evolve without breaking

  • Add fields freely. Deprecate with @deprecated(reason: "Use totalPrice. Removal after 2026-06-01.").
  • Breaking: removing a field or type; changing an output field from non-null to nullable; changing an input or argument from nullable to non-null; adding a required argument. Adding enum values can break clients with exhaustive switches.
  • Check field usage from operation logs before removing anything.
npx @graphql-inspector/cli diff schema.main.graphql schema.graphql   # exits non-zero on breaking

Hard rules

  • No DataLoader at module scope; one set per request.
  • No resolver issues a query inside a loop; batch or preload.
  • Never expose raw database errors in errors[].message; mask unexpected errors and log them with the request ID (Apollo formatError, Yoga maskedErrors default).
  • Every list field has a maximum page size.
  • Do not mirror the database schema 1:1; it couples clients to storage.
  • Mutations are named by intent (cancelOrder), not by CRUD on tables (updateOrderStatus) when there are business rules involved.

Verification

  1. Schema lints and graphql-inspector diff shows no unintended breaking changes.
  2. Run representative queries with SQL logging on: the number of queries is independent of list size.
  3. Test authorization by querying another tenant’s object via a nested path; expect null plus an error, never data.
  4. Send a query exceeding depth/cost limits and confirm it is rejected before execution.
  5. Integration tests execute operations against the real server (server.executeOperation in Apollo, yoga.fetch in Yoga) rather than calling resolver functions directly.

Output contract

## Schema changes (SDL diff)
## Resolver plan: field -> loader/service -> data source
## Breaking-change report (graphql-inspector output)
## Limits: depth, cost, page size, trusted documents status

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc