GraphQL Resolvers skill for Codex

Constant query counts and auth on every path. Codex measures SQL queries per GraphQL operation, then routes relation fields through per-request DataLoaders that respect the batch contract, including grouped and parameterised loaders. Authorization moves into the service and loader layer so nested paths and node lookups cannot bypass it, expensive queries are rejected by depth and cost limits, and tests assert the query count stays constant as lists grow.

  • Backend & APIs
  • $graphql-resolvers
  • 164 lines
  • 2 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- graphql-resolvers

Installs to ~/.agents/skills/graphql-resolvers/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $graphql-resolvers, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and fixes GraphQL resolvers - thin resolvers over a service layer, per-request DataLoader batching to kill N+1 queries, one-to-many and parameterised loaders, authentication in context and authorization at field and object level, query depth and cost limits, persisted queries, error masking and extensions codes - in Apollo Server, GraphQL Yoga, Strawberry, graphql-ruby, Spring for GraphQL and gqlgen. Use when a GraphQL API is slow, logs show hundreds of SQL queries per operation, adding auth to a schema, or implementing resolvers for a new type.

Use it when

  • A GraphQL operation is slow or logs show N+1 SQL queries
  • Writing resolvers for new types or relations
  • Adding authentication and field-level authorization to a schema
  • A public GraphQL endpoint needs protection from expensive queries

Use something else when

How to check the result

A query-count test returns the same small SQL count for 5 or 500 parents, sensitive fields are denied through root, nested and node paths, and over-deep queries are rejected.

The mistake it prevents

Creating DataLoaders at module level instead of per request, which caches one user's authorized data and serves it to the next.

~/.agents/skills/graphql-resolvers/SKILL.md

--- name: graphql-resolvers description: Writes and fixes GraphQL resolvers - thin resolvers over a service layer, per-request DataLoader batching to kill N+1 queries, one-to-many and… license: MIT ---

Done well means: each operation issues a bounded number of database queries regardless of list sizes, authorization is enforced where data is loaded rather than only at the root, expensive queries are rejected before execution, and clients see stable error codes but never internal messages.

Before changing anything

  1. Read AGENTS.md; identify server library and schema style (SDL-first, code-first).
  2. Measure: enable SQL logging or a per-operation query counter and run the slow operation. Record query count and latency. This is the baseline every change is judged against.
  3. Find how context is built (context: function) and whether loaders already exist (rg -n "DataLoader|dataloader|BatchMapping|dataloadgen").

Resolver rules

  • Resolvers are thin: parse args, call a service or loader, return. Business rules and authorization live in the service layer so REST, jobs and GraphQL share them.
  • Signature (parent, args, context, info). Use parent for the foreign key; never refetch the parent.
  • Return database IDs and let field resolvers load relations lazily through loaders.
  • Default resolvers handle plain fields; only write resolvers for computed or related fields.

DataLoader: the N+1 fix

A list of 50 posts each resolving author makes 50 queries. A loader collects the 50 keys during one tick and issues one WHERE id IN (...).

import DataLoader from "dataloader";

export function createLoaders(db: Db, viewer: Viewer | null) {
  return {
    userById: new DataLoader<string, User | null>(async (ids) => {
      const rows = await db.user.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? null);        // same length, same order as keys
    }),
    postsByAuthorId: new DataLoader<string, Post[]>(async (authorIds) => {
      const rows = await db.post.findMany({ where: { authorId: { in: [...authorIds] }, published: true } });
      const groups = new Map<string, Post[]>();
      for (const r of rows) {
        const list = groups.get(r.authorId) ?? [];
        list.push(r);
        groups.set(r.authorId, list);
      }
      return authorIds.map((id) => groups.get(id) ?? []);
    }),
  };
}

// Built per request, so cache and permissions never cross users
const server = new ApolloServer({ typeDefs, resolvers });
await startStandaloneServer(server, {
  context: async ({ req }) => {
    const viewer = await authenticate(req.headers.authorization);
    return { viewer, loaders: createLoaders(db, viewer) };
  },
});

const resolvers = {
  Post: { author: (post, _args, ctx) => ctx.loaders.userById.load(post.authorId) },
  User: { posts: (user, _args, ctx) => ctx.loaders.postsByAuthorId.load(user.id) },
};

Loader rules:

  • One loader instance per request. A module-level loader caches across users: a data leak.
  • Batch function contract: return an array of the same length and order as the keys; return null or an Error per missing key, never a shorter array.
  • Parameterised relations (posts(first: 10, status: DRAFT)): include args in the key with cacheKeyFn: (k) => JSON.stringify(k), or create one loader per arg combination.
  • Per-parent pagination in one SQL query needs a window function (ROW_NUMBER() OVER (PARTITION BY author_id ORDER BY created_at DESC)); a plain LIMIT limits across all parents.
  • After a mutation, loader.clear(id) or prime(id, value) so the response reflects the write.

Equivalents: Strawberry strawberry.dataloader.DataLoader(load_fn=...) created in get_context; graphql-ruby GraphQL::Dataloader with dataloader.with(Sources::RecordById, User).load(id); Spring for GraphQL @BatchMapping; gqlgen with dataloadgen middleware; Hot Chocolate [DataLoader] source-generated loaders.

Authorization

  • Authenticate once in the context function. Invalid token: reject the request (401) or set viewer: null, per the API’s policy; never throw inside random resolvers.
  • Authorize where objects are loaded. Put the check in the service or loader layer so every path to an object (root query, nested field, node(id:) interface) enforces it. A root-only check is bypassed by { me { team { members { salary } } } }.
  • Field-level rules for sensitive fields (email, salary): resolver checks viewer.can("read:salary", parent), returning null (nullable field) or an error.
  • Schema directives (@auth(requires: ADMIN)) are fine as declarations but must compile to the same service checks; test them per field.
  • Hide existence: unauthorised single-object lookups return null / NOT_FOUND, not FORBIDDEN.

Protect the server from expensive queries

ControlDefault
Max depth8-12 for public APIs
Cost / complexity limitAssign list fields a multiplier from first/limit; reject above a budget
Max page sizeEnforce first <= 100 in resolvers or schema validation
Persisted queries / operation allowlistFor first-party clients, accept only registered operation hashes
IntrospectionDisable in production for private APIs
TimeoutsPer-operation deadline; cancel DB queries on abort
Aliases and batchingLimit aliases and batched operations per request (aliases bypass naive rate limits)

Use the validation rules or plugins your server provides (Yoga/Envelop plugins, Apollo plugins, graphql-ruby max_depth/max_complexity, Strawberry QueryDepthLimiter).

Errors

  • Throw GraphQLError with extensions: { code: "NOT_FOUND" | "FORBIDDEN" | "BAD_USER_INPUT" | ... }. Clients branch on code.
  • Mask unexpected errors: GraphQL Yoga masks by default; in Apollo Server use formatError to replace non-GraphQLError messages with a generic one and log the original with a trace ID.
  • Nullability decides blast radius: an error in a non-null field nulls its parent, up to the nearest nullable ancestor or the whole data. Keep list items and fallible relations nullable (graphql-schema-design).
  • For expected business outcomes (validation failures on mutations), prefer result union types or payload errors fields over top-level errors.

Verification

  • Query-count test per hot operation: run it with fixtures of N parents and assert the SQL count is constant (for example 3), not N+1:
const { queries } = await withQueryCounter(() => execute({ schema, document: POSTS_WITH_AUTHORS, contextValue }));
expect(queries).toBeLessThanOrEqual(3);
  • Auth tests: a non-admin requesting salary through every path (root, nested, node) gets null or an error.
  • Depth and cost tests: a deliberately nested or huge-first query is rejected before execution.
  • Compare latency and query count to the baseline from step 2.

Workflow checklist

  • Baseline query count and latency for slow operations
  • Create per-request loaders in context; route relation fields through them
  • Move authorization into the service/loader layer; add field rules
  • Add depth, cost and page-size limits; persisted queries for first-party clients
  • Mask unexpected errors; use extensions.code
  • Add query-count, auth-path and limit tests

Gotchas

  • Calling await on each loader inside a for loop serialises loads and defeats batching; map to promises and Promise.all them, or just return loader.load() from field resolvers.
  • ORM lazy loading inside resolvers (post.author on an ORM object) triggers hidden queries.
  • Loaders keyed by object instead of primitive without cacheKeyFn never hit the cache.
  • Subscriptions reuse one context per connection: create fresh loaders per event.
  • Federated subgraphs resolve __resolveReference per entity; batch those with loaders too (graphql-federation).
  • For N+1 problems outside GraphQL, use n-plus-one-queries.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "GraphQL Resolvers"
  short_description: "Constant query counts and auth on every path"
  default_prompt: "$graphql-resolvers This GraphQL query fires 400 SQL queries, add DataLoader and fix it"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem