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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- graphql-resolvers Installs to ~/.agents/skills/graphql-resolvers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install graphql-resolvers The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project graphql-resolvers Installs to .agents/skills/graphql-resolvers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/graphql-resolvers.zipunzip -o graphql-resolvers.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) graphql-resolvers PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You are designing types, nullability and pagination in the schema: use graphql-schema-design
- The work spans subgraphs and composition: use graphql-federation
- The N+1 problem is in REST or ORM code, not GraphQL: use n-plus-one-queries
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.
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
- Read
AGENTS.md; identify server library and schema style (SDL-first, code-first). - 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.
- 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). Useparentfor 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
nullor anErrorper missing key, never a shorter array. - Parameterised relations (
posts(first: 10, status: DRAFT)): include args in the key withcacheKeyFn: (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 plainLIMITlimits across all parents. - After a mutation,
loader.clear(id)orprime(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 checksviewer.can("read:salary", parent), returningnull(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, notFORBIDDEN.
Protect the server from expensive queries
| Control | Default |
|---|---|
| Max depth | 8-12 for public APIs |
| Cost / complexity limit | Assign list fields a multiplier from first/limit; reject above a budget |
| Max page size | Enforce first <= 100 in resolvers or schema validation |
| Persisted queries / operation allowlist | For first-party clients, accept only registered operation hashes |
| Introspection | Disable in production for private APIs |
| Timeouts | Per-operation deadline; cancel DB queries on abort |
| Aliases and batching | Limit 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
GraphQLErrorwithextensions: { code: "NOT_FOUND" | "FORBIDDEN" | "BAD_USER_INPUT" | ... }. Clients branch oncode. - Mask unexpected errors: GraphQL Yoga masks by default; in Apollo Server use
formatErrorto 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
errorsfields 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
salarythrough every path (root, nested,node) getsnullor an error. - Depth and cost tests: a deliberately nested or huge-
firstquery 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
awaiton each loader inside aforloop serialises loads and defeats batching; map to promises andPromise.allthem, or just returnloader.load()from field resolvers. - ORM lazy loading inside resolvers (
post.authoron an ORM object) triggers hidden queries. - Loaders keyed by object instead of primitive without
cacheKeyFnnever hit the cache. - Subscriptions reuse one context per connection: create fresh loaders per event.
- Federated subgraphs resolve
__resolveReferenceper 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.yaml
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.