Claude Fable 5.1 & GPT-6 Astra packages are live

Graphql

Free

GraphQL schema and server rules — bounding query cost, killing N+1 with dataloaders, per-field authorization, and errors clients can act on.

247 lines9.7 KB Gemini API
targetModels
Gemini 3.8 FlashGemini 3.7 FlashGemini 3.1 ProGemini 3 FamilyFuture Gemini Models
name
graphql
category
API
description
GraphQL schema and server rules — bounding query cost, killing N+1 with dataloaders, per-field authorization, and errors clients can act on.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for Gemini: scripts/model-profiles.json -->

#Purpose

Rules for building a GraphQL API. GraphQL moves query construction from the server to the client. That is the feature and the entire risk surface: the client now decides how expensive a request is, and how deep it goes.

Every rule here follows from that. Choose GraphQL when clients genuinely need varied shapes; if every consumer fetches the same thing, REST is less machinery. → API/rest


#Bound the cost of a query

A public GraphQL endpoint without cost controls is an open denial-of-service target. All four controls below are needed — none is sufficient alone.

graphql
# Without depth limiting, this recurses until the server dies
query { user { friends { friends { friends { friends { id } } } } } }
ControlTypical valueTool
Maximum depth10–15graphql-depth-limit
Query complexity budgetPer-operation scoregraphql-query-complexity
Maximum aliases / breadth100Custom validation rule
Timeout10sServer config
Persisted queriesAllowlist onlygraphql-codegen + APQ

Persisted (allowlisted) queries are the strongest control. The client sends a hash; the server executes only queries it has seen at build time. Arbitrary queries become impossible. Use this for first-party clients; keep cost limits for any genuinely public endpoint.

Assign complexity weights by real cost — a paginated list costs limit × child cost, not 1:

ts
@Field(() => [Order], { complexity: ({ args, childComplexity }) =>
  args.first * childComplexity })

Never expose introspection on a public production endpoint. It hands an attacker the complete schema, including fields you forgot were reachable.


#N+1 is structural, not accidental

Resolvers run per field per object. A list of 100 orders each resolving customer issues 100 queries — the resolver has no idea it is in a list.

ts
// One batched query per tick, deduplicated by key
const customerLoader = new DataLoader(async (ids: readonly string[]) => {
  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);   // must return in input order
});

Three rules that are easy to get wrong:

  1. Create loaders per request, in the context factory. A module-scope DataLoader caches across users and leaks data between them.
  2. Return results in input order, one entry per key, null for misses. Returning the raw query result mismatches keys silently.
  3. Batch relations, not just entities. A one-to-many needs a loader keyed by the parent id that groups the result.

Assert resolver query counts in tests — this regresses every time someone adds a field. → Database/query-optimization


#Authorization is per field

There is no endpoint to guard. A single query can traverse from a public field into a sensitive one, so authorization belongs in the resolver or the type layer.

ts
Order: {
  costBasisCents: (order, _args, ctx) => {
    if (!ctx.can("order:read_financials", order.tenantId)) return null;
    return order.costBasisCents;
  },
}
  • Check on the object being resolved, not on the root argument. A nested path reaches objects the top-level check never saw.
  • Default to deny: a field with no explicit policy should fail review.
  • Never rely on the client not asking for a field.
  • Depth-limit and cost controls are not authorization — they limit volume, not access. → Security/authorization

#Schema design

graphql
type Order implements Node {
  id: ID!
  status: OrderStatus!
  totalCents: Int!
  currency: String!
  items(first: Int = 20, after: String): OrderItemConnection!
}

union CreateOrderResult = CreateOrderSuccess | ValidationFailed | InsufficientFunds
RuleWhy
Non-null (!) only where truly guaranteedA null in a non-null field nulls the whole parent
Enums, not free stringsValidated by the schema, self-documenting
Connections for listsPagination is impossible to retrofit → API/pagination
Result unions for mutationsExpected failures are typed, not exceptions
One input object per mutationAdding a field stays non-breaking
Global ID opaque and namespacedPrevents cross-type id confusion

The non-null propagation rule surprises people: if a String! resolver returns null, GraphQL nulls the parent object, and if that is also non-null, it propagates upward — potentially nulling the entire data payload. Be conservative with !.

Never version a GraphQL schema with /v2. Deprecate fields in place:

graphql
amount: Int! @deprecated(reason: "Use totalCents. Removed after 2026-09-01.")

#Errors

GraphQL returns 200 with a top-level errors array. Clients need more structure than a message string.

json
{ "errors": [{
    "message": "Insufficient funds",
    "path": ["createOrder"],
    "extensions": { "code": "INSUFFICIENT_FUNDS", "requestId": "req_01J8Z" }
}] }
  • Put a stable machine code in extensions.code. Clients branch on the code.
  • Model expected failures (validation, business rules) as result unions in the schema; reserve the errors array for genuinely exceptional conditions.
  • Mask internal errors in production — maskedErrors: true in Yoga, or a formatError hook. A stack trace in extensions is an information leak.
  • Include a requestId in every response.

#Operations

  • Disable introspection and the GraphiQL playground in production.
  • Log per-operation name, complexity score and duration — not the raw query string, which contains user data.
  • @defer/@stream change response framing; confirm every client supports the incremental delivery protocol before enabling them.
  • Caching is per-field, not per-URL. HTTP caches are useless here; use persisted queries plus a response cache keyed on the operation hash and the viewer.

#Anti-patterns

Anti-patternWhy it failsFix
No depth or complexity limitRecursive query kills the serverDepth + complexity + timeout
Introspection enabled publiclyFull schema handed to attackersDisable in production
Resolvers without DataLoaderN+1 on every list fieldPer-request loaders
Module-scope DataLoaderCaches across users; data leaksCreate in the context factory
Loader returning unordered resultsSilent key/value mismatchMap back into input order
Authorization only at the rootNested paths bypass itCheck on each resolved object
Everything non-nullOne null nulls the whole response! only where guaranteed
Lists without connectionsPagination cannot be retrofittedConnection pattern from the start
Business failures thrown as errorsUntyped, unhandleable by clientsResult unions
/v2 endpointDoubles the schema@deprecated in place
Raw query strings in logsLogs user dataLog operation name and hash
Unmasked errors in productionStack traces leak internalsmaskedErrors / formatError

#Checklist

  • Verify: Query depth, complexity, breadth and timeout limits are all enforced
  • Verify: Complexity weights reflect real cost, including pagination multipliers
  • Verify: First-party clients use persisted/allowlisted queries
  • Verify: Introspection and the playground are disabled in production
  • Verify: Every relation field resolves through a per-request DataLoader
  • Verify: Loaders return one result per key, in input order
  • Verify: Resolver query counts are asserted in tests
  • Verify: Authorization is checked on each resolved object, defaulting to deny
  • Verify: Non-null is used only where the value is genuinely guaranteed
  • Verify: All list fields use the connection pattern
  • Verify: Expected failures are typed as result unions
  • Verify: Errors carry a stable extensions.code and a requestId
  • Verify: Internal errors are masked in production
  • Verify: Deprecation uses @deprecated with a removal date, not a new endpoint

#Anchors (restated last, read last)

The rules that must hold when you stop, repeated here because the end of the context is what you act on:

  • Never expose introspection on a public production endpoint. It hands an attacker the complete schema, including fields you forgot were reachable.

  • Never version a GraphQL schema with /v2. Deprecate fields in place:

  • Query depth, complexity, breadth and timeout limits are all enforced

  • Complexity weights reflect real cost, including pagination multipliers

  • First-party clients use persisted/allowlisted queries

  • Introspection and the playground are disabled in production

  • Every relation field resolves through a per-request DataLoader

  • Loaders return one result per key, in input order

Before reporting done, prove the module still imports — run the line for this stack and paste its output:

bash
python -c "import <package>"          # Python: the package you changed
node -e "require('./<entry>')"       # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./...                        # Go