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.
- name
graphql-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
- The API is resource-oriented JSON over HTTP — use rest-api-design
- The N+1 problem is in plain ORM code outside GraphQL — use n-plus-one-queries
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.
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 kind | Nullability |
|---|---|
id, scalar columns on the same row | Non-null |
| Relations resolved from another service or table | Nullable |
| Lists | [Item!]! (list and items non-null; empty list, not null) |
| Arguments | Non-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
nullplus an error, or by schema contracts per audience; do not rely on clients not asking.
6. Protect the endpoint
| Threat | Control |
|---|---|
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 clients | Trusted 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 discovery | Disable introspection in production for private APIs; it is not a security boundary, just noise reduction |
| Slow resolvers | Timeouts 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 (ApolloformatError, YogamaskedErrorsdefault). - 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
- Schema lints and
graphql-inspector diffshows no unintended breaking changes. - Run representative queries with SQL logging on: the number of queries is independent of list size.
- Test authorization by querying another tenant’s object via a nested path; expect null plus an error, never data.
- Send a query exceeding depth/cost limits and confirm it is rejected before execution.
- Integration tests execute operations against the real server (
server.executeOperationin Apollo,yoga.fetchin 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.