Graphql api development
AI-powered GraphQL API design, implementation, and optimization. Covers schema-first design, resolver architecture, query optimization with DataLoader for N+1 prevention, mutation patterns with idempotency, real-time subscriptions, Apollo Federation for distributed graphs, security hardening (depth limiting, rate limiting, authz), and production performance (persisted queries, caching, CDN integration). Primary keyword clusters: GraphQL schema design best practices, Apollo Federation subgraph patterns, DataLoader N+1 query optimization, GraphQL security depth limiting rate limiting, GraphQL persisted queries performance, GraphQL subscription real-time patterns, GraphQL error handling union types, GraphQL pagination relay cursor connection, GraphQL caching strategies production, GraphQL resolver architecture patterns. Designed for agentic platforms — Claude Code, Codex, Cursor, Gemini CLI, OpenClaw, GitHub Copilot, Windsurf, and OpenCode.From its SKILL.md
npx -y skills add JPeetz/agent-skills --skill graphql-api-developmentAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
33.8 KB, ~7.8k tokens by cl100k_base, as published. Nobody here has run it
GraphQL API Development Agent Skill
Design, implement, and optimize production-grade GraphQL APIs with a schema-first approach. This skill equips an agent to build performant, secure, and federated GraphQL services — not just lint a schema.
Quick Reference
| Dimension | What to Check | Key Indicators |
|---|---|---|
| 🔵 Schema Design | Naming, types, pagination, errors | Verb-first mutations, Relay pagination, union errors |
| 🟠 Resolvers | Data loading, context, error handling | DataLoader usage, null propagation, partial errors |
| ⚡ Query Optimization | N+1 prevention, field selection, complexity | Batched loads, DataLoader per request, query cost |
| 🟣 Mutations | Input types, idempotency, atomicity | Single input arg, idempotency keys, thin resolvers |
| 📡 Subscriptions | Event sources, filtering, auth | AsyncIterator, withFilter, ws auth |
| 🏗️ Federation | Entity resolution, shared types, contracts | @key directives, reference resolvers, contract tags |
| 🔒 Security | Authz, depth limiting, rate limiting | Field-level auth, graphql-depth-limit, persist-authenticated |
| 🚀 Performance | Caching, persisted queries, CDN | APQ, @cacheControl, response compression |
Severity Scale:
- 🔴 BLOCKER — Must fix before production. SQL injection via resolvers, auth bypass, unbounded recursion, schema that returns secrets.
- 🟠 MAJOR — Should fix. N+1 on hot path, missing depth limit, no error handling on critical mutations, federation entity mismatch.
- 🟡 MINOR — Nice to fix. Deprecated field usage, inconsistent naming convention, missing description strings.
- ⚪ NIT — Optional. Field ordering preference, comment style, type name bikeshedding.
When to Use This Skill
Activate when the user asks you to:
- "Design a GraphQL schema for..." / "Create a GraphQL API for..."
- "Review this GraphQL schema" / "Check my resolvers for N+1 queries"
- "Set up Apollo Federation" / "Convert my monolith to subgraphs"
- "Add subscriptions to my GraphQL API" / "Implement real-time updates with GraphQL"
- "Optimize my GraphQL performance" / "Add persisted queries"
- "Harden my GraphQL API" / "Add depth limiting and rate limiting"
- "Implement pagination in GraphQL" / "Add Relay-style cursor connections"
- "Design error handling for GraphQL" / "Use union types for errors"
- "Set up DataLoader" / "Fix N+1 queries in my resolvers"
- Any request combining "GraphQL" + design, review, optimize, secure, or implement
Do NOT Activate For
Near-miss negatives — these mention GraphQL but are NOT design/implementation:
- REST vs GraphQL comparison: "Should I use GraphQL or REST?" — technology evaluation, not GraphQL development.
- GraphQL client usage: "How do I use useQuery in Apollo Client?" — client-side consumption, not API development.
- Generic debugging: "My GraphQL query returns null but the database has data" without schema/resolver context — debugging, not development.
- Tooling questions: "Which GraphQL IDE should I use?" / "How to set up GraphiQL?" — tool selection, not API design.
- General Q&A about GraphQL concepts: "What is a resolver?" / "How does introspection work?" — education, not implementation.
- GraphQL migration without design: "Move my REST endpoint to GraphQL" without schema design or resolver planning — migration planning, not API development.
- GraphQL gateway/proxy setup without schema work: "Set up Apollo Router" with no subgraph design — infrastructure, not API development.
When in doubt, ask: "Are you looking for schema design, resolver implementation, or performance optimization for your GraphQL API?"
Common Pitfalls & Anti-Patterns
❌ GraphQL Anti-Patterns
-
N+1 Queries in Resolvers — The most common GraphQL performance killer. Every resolver firing individual DB calls cascades into hundreds of queries. Always batch with DataLoader, not per-field queries.
-
Over-fetching in Resolvers — Resolvers returning all columns when the query only asks for
idandname. Use field-aware database projections or parent-to-child delegation. -
Mutation Resolver as Business Logic Dump — Thick mutation resolvers with validation, authorization, side effects, and notifications. Keep resolvers thin: validate → authorize → delegate to service layer → return result.
-
String-Based Error Handling — Returning
nullor magic strings for errors. Use typed error unions or theerrorsextensions payload so clients can pattern-match instead of string-parse. -
Monolithic Schema Before Federation — Building one massive schema and then retrofitting federation. Design with federation from the start: define entity boundaries,
@keyfields, and subgraph ownership. -
No Depth or Complexity Limits — Unbounded recursive queries can bring down a server. A single malicious query fetching
user.posts.author.posts.authorrecursively is a DoS vector. Always setgraphql-depth-limitor query cost analysis. -
Authentication in Resolvers, Not Middleware — Checking
context.userinline in every resolver bloats code. Extract auth to a GraphQL context function or a schema directive so resolvers receive an already-authenticated (or rejected) context. -
Subscription Leaks — AsyncIterators that never clean up lead to memory pressure. Every subscription source must have a proper teardown in the
subscribefunction's return{ unsubscribe }. -
Hardcoded Field Selections in Business Logic — Business code that assumes specific GraphQL selections (
if (info.fieldNodes...)). Use attribute-based access patterns or GraphQL-aware ORMs instead. -
Ignoring the
extensionsField — The extensions field is the GraphQL protocol's extensibility point. Use it for tracing (Apollo Tracing), request IDs, deprecation warnings, and rate limit headers — don't invent custom envelopes.
✅ GraphQL Quality Checklist
Before claiming implementation complete, verify:
- Schema uses verb-first mutation naming (
createUser, notUserCreate) - All list fields are paginated (Relay Connection or simplified offset)
- Errors use typed unions, not loose strings
- Every resolver with a DB call uses DataLoader
- Depth limit and query cost analysis are configured
- Mutations accept a single input type argument
- Subscriptions have teardown/unsubscribe logic
- Authentication happens in context, not individual resolvers
- Federation entities have
@keydirectives and reference resolvers - Persisted queries are enabled for production builds
- All types and fields have
descriptionstrings - Deprecated fields use
@deprecated(reason: "...")with a migration path
Workflow
Phase 1: Schema Design
Design the schema first — the schema is the contract. Resolvers implement it, not the other way around.
1.1 Naming Conventions
| Construct | Convention | Example |
|---|---|---|
| Types | PascalCase, singular noun | User, Post, Payment |
| Query fields | camelCase, noun or noun phrase | user(id:), searchPosts |
| Mutations | camelCase, verb + object | createPost, cancelOrder |
| Input types | PascalCase, suffixed with Input | CreatePostInput, UserFilter |
| Enum values | UPPER_SNAKE_CASE | OrderStatus.PENDING, PAYMENT_FAILED |
| Payload types | PascalCase, suffixed with Payload | CreatePostPayload, LoginPayload |
| Union errors | PascalCase, suffixed with Error | ValidationError, NotFoundError |
Critical Rule: Mutations MUST be verb-first. postCreate is wrong;
createPost is correct. This isn't style — it's a GraphQL spec expectation
that tooling (Apollo Studio, GraphiQL introspection) sorts on.
1.2 Pagination Patterns
Always paginate list fields. Never return a bare [User!]!.
Relay Cursor Connections (Preferred):
type Query {
users(
first: Int
after: String
last: Int
before: String
filter: UserFilter
): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
When to use Relay spec: public APIs, APIs consumed by multiple clients, when you need stable cursor-based pagination, or when client uses Relay/Apollo Client pagination helpers.
Simplified Offset Pagination (Internal APIs):
type Query {
users(limit: Int = 20, offset: Int = 0): UserPage!
}
type UserPage {
items: [User!]!
totalCount: Int!
hasMore: Boolean!
}
When to use offset: internal/admin APIs, when clients need to jump to arbitrary pages, or when data set is small and stable.
Anti-pattern — Never:
# ❌ Unpaginated list — unbounded response, DoS risk
users: [User!]!
1.3 Error Handling Patterns
Don't overload null to mean "error". Structure your errors.
Typed Union Errors (Recommended):
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
type CreatePostPayload {
post: Post
errors: [CreatePostError!]!
}
union CreatePostError = ValidationError | UnauthorizedError | RateLimitError
type ValidationError {
message: String!
field: String!
code: String!
}
type UnauthorizedError {
message: String!
}
type RateLimitError {
message: String!
retryAfterSeconds: Int!
}
Clients pattern-match on __typename:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
}
errors {
__typename
... on ValidationError { message field code }
... on RateLimitError { message retryAfterSeconds }
}
}
}
Top-Level Errors (for partial failures):
Use the standard GraphQL errors array for infrastructure errors (auth,
rate limit, internal server error). Use typed union errors for business
logic errors the client should handle.
Anti-patterns:
# ❌ Magic null — was it not found? forbidden? deleted?
user(id: "1"): User
# ❌ Stringly-typed error — client must parse strings
type CreatePostPayload {
post: Post
error: String # "VALIDATION_ERROR: title required"
}
1.4 Schema Documentation
Every type and field MUST have a description:
"""
A user account in the system. Users can create posts,
comment, and manage their profile.
"""
type User {
"""Unique identifier, stable across renames."""
id: ID!
"""Display name shown on posts and comments."""
name: String!
"""Set when the account was created. Immutable."""
createdAt: DateTime!
}
Descriptions feed into GraphiQL, Apollo Studio, and codegen tools. Undocumented schemas are tech debt.
Phase 2: Resolver Architecture
2.1 Resolver Signature
Every resolver receives (parent, args, context, info):
const resolvers = {
Query: {
user: async (_parent, { id }, context, info) => {
// parent — result from parent resolver (null for root queries)
// args — GraphQL arguments ({ id: "42" })
// context — per-request shared state (auth, loaders, db)
// info — AST, field name, return type, path
return context.loaders.user.load(id);
},
},
};
2.2 DataLoader & N+1 Prevention
The N+1 problem: resolving posts.author for 10 posts fires 11 queries
(1 for posts + 10 individual author queries). DataLoader coalesces the
10 author loads into a single WHERE id IN (...) query.
Setup (per-request):
import DataLoader from "dataloader";
function createLoaders(db) {
return {
user: new DataLoader(async (ids: readonly string[]) => {
const users = await db.users.findByIds([...ids]);
// MUST return in same order as input ids
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}),
postsByAuthor: new DataLoader(async (authorIds: readonly string[]) => {
const posts = await db.posts.findByAuthorIds([...authorIds]);
const grouped = new Map<string, Post[]>();
for (const post of posts) {
const list = grouped.get(post.authorId) || [];
list.push(post);
grouped.set(post.authorId, list);
}
return authorIds.map(id => grouped.get(id) || []);
}),
};
}
// In Apollo Server context:
const server = new ApolloServer({
schema,
context: async ({ req }) => ({
user: await authenticate(req),
loaders: createLoaders(db),
}),
});
Critical DataLoader Rules:
- Create new DataLoader instances per request — Never reuse across requests. Caching across requests causes stale data and security leaks.
- Return arrays in the same order as input keys — DataLoader matches by index. Wrong order = wrong data.
- Batch function must accept and return arrays — Single-item batch functions defeat the purpose.
- Handle nulls for not-found — Return
null(not throw) for individual missing items so other items still resolve. - Use
DataLoaderinstance in context, not imported globally.
2.3 Resolver Chain Patterns
Parent-to-Child Delegation:
const resolvers = {
Query: {
post: async (_, { id }, { loaders }) => loaders.post.load(id),
},
Post: {
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
comments: (post, _, { loaders }) => loaders.commentsByPost.load(post.id),
},
Comment: {
author: (comment, _, { loaders }) => loaders.user.load(comment.authorId),
},
};
Field-Level Authorization:
const resolvers = {
User: {
email: (user, _, { currentUser }) => {
if (currentUser?.id !== user.id && !currentUser?.isAdmin) {
return null; // Field-level hide, not error
}
return user.email;
},
ssn: (user, _, { currentUser }) => {
throw new ForbiddenError("Insufficient permissions");
},
},
};
Computed Fields with Args:
const resolvers = {
Post: {
excerpt: (post, { maxLength = 200 }) => {
return post.body.length > maxLength
? post.body.slice(0, maxLength) + "..."
: post.body;
},
},
};
2.4 Error Propagation
GraphQL null-propagates: if a non-null field resolver throws, the error bubbles up to the first nullable parent. Design your schema nullability with this in mind:
type Query {
# ❌ If post.author.email throws, the entire query fails
post(id: ID!): Post!
# ✅ post.author.email can fail without killing the whole query
post(id: ID!): Post
}
For partial data, return what you can + errors in the extensions payload.
GraphQL can return both data and errors simultaneously.
Phase 3: Mutation Design
3.1 Input Types
Every mutation MUST accept a single input argument of a dedicated input type:
# ✅ Single input type
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) { ... }
}
input CreatePostInput {
title: String!
body: String!
tags: [String!]
published: Boolean = false
}
Why single input?
- Evolvable: adding fields doesn't break the mutation signature
- Self-documenting: the input type describes the entire payload
- Client-friendly: single variable with nested fields vs flat positional args
Anti-pattern:
# ❌ Multiple top-level args — fragile, not evolvable
createPost(title: String!, body: String!, tags: [String!]): Post!
3.2 Idempotency Keys
For mutations that must not be duplicated (payments, orders, email sends), accept an idempotency key:
input ProcessPaymentInput {
idempotencyKey: String!
amount: Money!
paymentMethodId: ID!
}
The resolver checks if the idempotencyKey has been seen:
async function processPayment(_, { input }, { db, paymentService }) {
const existing = await db.payments.findByKey(input.idempotencyKey);
if (existing) return { payment: existing, errors: [] };
// Process payment — if this fails and client retries with same key,
// the above check prevents double-charge
const payment = await paymentService.charge(input);
await db.payments.create({ ...payment, idempotencyKey: input.idempotencyKey });
return { payment, errors: [] };
}
3.3 Mutation Response Pattern
Always return a payload type, never the entity directly:
# ✅ Payload type — evolvable
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
type CreateUserPayload {
user: User
errors: [CreateUserError!]!
}
# ❌ Direct entity — no room for errors or metadata
type Mutation {
createUser(input: CreateUserInput!): User!
}
3.4 Thin Resolvers, Thick Services
Mutations are entry points. They should not contain business logic:
// ✅ Thin resolver
const resolvers = {
Mutation: {
createPost: async (_, { input }, ctx) => {
const validated = await ctx.services.postValidator.validate(input);
await ctx.services.auth.assertCanCreatePost(ctx.user, validated);
const post = await ctx.services.postService.create(validated);
await ctx.services.eventBus.publish("post.created", { post });
return { post, errors: [] };
},
},
};
// ❌ Thick resolver with inline logic
const resolvers = {
Mutation: {
createPost: async (_, { input }, ctx) => {
if (!input.title || input.title.length < 3) { /* ... */ }
if (!ctx.user) { /* ... */ }
const slug = input.title.toLowerCase().replace(/\s+/g, "-");
const existing = await ctx.db.posts.findOne({ slug });
if (existing) { /* ... */ }
// 50 more lines...
},
},
};
Phase 4: Subscription Patterns
4.1 Event-Driven Subscriptions
Use PubSub (or Redis-backed pub/sub in production) as an event bus:
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const POST_CREATED = "POST_CREATED";
const resolvers = {
Mutation: {
createPost: async (_, { input }, ctx) => {
const post = await ctx.services.postService.create(input);
await pubsub.publish(POST_CREATED, { postCreated: post });
return { post, errors: [] };
},
},
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED]),
},
},
};
4.2 Filtered Subscriptions
Use withFilter to only deliver events the subscriber cares about:
import { withFilter } from "graphql-subscriptions";
const resolvers = {
Subscription: {
commentAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(["COMMENT_ADDED"]),
(payload, variables, context) => {
// Only deliver if the comment is on the post the client watches
return payload.commentAdded.postId === variables.postId;
},
),
},
},
};
4.3 Authentication on Subscriptions
WebSocket connections carry auth differently than HTTP:
// Apollo Server 4 with graphql-ws:
const server = new ApolloServer({
schema,
plugins: [
{
async serverWillStart() {
return {
async drainServer() { pubsub.close(); },
};
},
},
],
});
// In context function, handle WebSocket auth:
const context = async ({ req, connectionParams }) => {
if (connectionParams) {
// WebSocket connection
const token = connectionParams.authorization || "";
return { user: await verifyToken(token), loaders: createLoaders(db) };
}
// HTTP request
const token = req.headers.authorization || "";
return { user: await verifyToken(token), loaders: createLoaders(db) };
};
4.4 Cleanup
Every subscription source needs a teardown:
const resolvers = {
Subscription: {
liveCursor: {
subscribe: async function* (_, { documentId }, { db }) {
// Setup: watch database changes
const watcher = await db.documents.watch(documentId);
try {
while (true) {
const change = await watcher.nextChange();
yield { liveCursor: change };
}
} finally {
// Teardown: always clean up, even on disconnect
await watcher.close();
}
},
},
},
};
Phase 5: Apollo Federation
5.1 Entity Definition
Define entities with @key directives:
# Users subgraph
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.6")
type User @key(fields: "id") {
id: ID!
username: String!
email: String! @shareable
}
# Posts subgraph
type User @key(fields: "id") {
id: ID!
posts: [Post!]!
}
type Post @key(fields: "id") {
id: ID!
title: String!
authorId: ID! # Only stored in Posts subgraph
author: User!
}
5.2 Reference Resolvers
Provide reference resolvers for each entity:
// Posts subgraph
const resolvers = {
User: {
__resolveReference: async ({ id }, { loaders }) => {
// Fetch the fields this subgraph contributes for the User entity
return loaders.userById.load(id);
},
posts: (user, _, { loaders }) => {
return loaders.postsByAuthor.load(user.id);
},
},
Post: {
__resolveReference: async ({ id }, { loaders }) => {
return loaders.post.load(id);
},
author: (post, _, { loaders }) => {
return { __typename: "User", id: post.authorId };
// The gateway will resolve User fields from other subgraphs
},
},
};
5.3 Federation Best Practices
- Each subgraph owns its data. The Posts subgraph stores
Postdata; the Users subgraph storesUserdata. Don't duplicate data across subgraphs. - Use
@shareablefor fields defined in multiple subgraphs. - Keep
@keyfields simple. Composite keys (@key(fields: "orgId userId")) are fine but avoid deeply nested keys. @requiresfor cross-subgraph data needs:
type Product @key(fields: "id") {
id: ID!
price: Float # stored in Products subgraph
}
type Review @key(fields: "id") {
id: ID!
product: Product!
priceAtReview: Float @requires(fields: "product { price }")
}
- Use contracts (
@tag,@inaccessible) to version public vs internal APIs. - Test
_entitiesqueries directly — they're the gateway's query API.
Phase 6: Security Hardening
6.1 Depth Limiting
Prevent recursive query DoS attacks:
npm install graphql-depth-limit
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)], // Max 7 levels of nesting
});
A depth limit of 7 allows:
query { # depth 0
user { # depth 1
posts { # depth 2
author { # depth 3
posts { # depth 4
author { # depth 5
posts { # depth 6
author { # depth 7
name # depth 8 — REJECTED
}
}
}
}
}
}
}
}
6.2 Query Cost Analysis
Depth limiting isn't enough — a shallow but wide query can still be expensive:
# Depth 3, but fetches A LOT of data:
query HeavyQuery {
users(first: 100) {
posts(first: 50) {
comments(first: 50) {
body # 100 × 50 × 50 = 250,000 nodes
}
}
}
}
Use query cost analysis:
npm install graphql-cost-analysis
import costAnalysis from "graphql-cost-analysis";
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(7),
costAnalysis({
maximumCost: 1000,
defaultCost: 1,
variables: {}, // Pass request variables
}),
],
});
Cost multipliers — adjust per type:
type Query {
user(id: ID!): User # cost: 1
users(first: Int, after: String): UserConnection # cost: first × complexity
search(query: String!): [SearchResult!]! # cost: 10 (expensive)
}
6.3 Rate Limiting
Rate limiting should be per-operation, not just per-endpoint:
import { createRateLimitDirective } from "graphql-rate-limit";
const { rateLimitDirectiveTypeDefs, rateLimitDirectiveTransformer } =
createRateLimitDirective();
// Apply rate limit to specific fields:
const typeDefs = gql`
${rateLimitDirectiveTypeDefs}
type Mutation {
login(input: LoginInput!): LoginPayload! @rateLimit(limit: 5, duration: 60)
createPost(input: CreatePostInput!): CreatePostPayload! @rateLimit(limit: 30, duration: 60)
}
`;
6.4 Authentication & Authorization
Pattern: Schema Directives for Auth:
directive @auth(requires: Role = USER) on OBJECT | FIELD_DEFINITION
enum Role {
ADMIN
USER
PUBLIC
}
type Query {
me: User! @auth
users: [User!]! @auth(requires: ADMIN)
publicPosts: [Post!]! @auth(requires: PUBLIC)
}
Implement as a directive transformer or check in context:
const context = async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "") || "";
let user = null;
if (token) {
try { user = await verifyToken(token); } catch { /* unauthenticated */ }
}
return { user, loaders: createLoaders(db) };
};
// In resolvers:
function assertAuthenticated(ctx) {
if (!ctx.user) throw new AuthenticationError("You must be logged in");
}
function assertAuthorized(ctx, requiredRole) {
assertAuthenticated(ctx);
if (!ctx.user.roles.includes(requiredRole)) {
throw new ForbiddenError(`Requires role: ${requiredRole}`);
}
}
6.5 Introspection Control
Disable introspection in production or gate it:
const server = new ApolloServer({
schema,
introspection: process.env.ALLOW_INTROSPECTION === "true",
// Or gate behind auth:
// introspection: true,
// plugins: [{
// async requestDidStart({ request }) { ... }
// }]
});
Phase 7: Performance Optimization
7.1 Persisted Queries
Automatic Persisted Queries (APQ) reduce bandwidth and improve cacheability:
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
// Client side:
const link = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true,
}).concat(httpLink);
// Server side:
import responseCachePlugin from "apollo-server-plugin-response-cache";
const server = new ApolloServer({
schema,
plugins: [
responseCachePlugin({
sessionIdFromContext: (ctx) => ctx.user?.id || null,
// Private data varies by user; public data can be fully cached
}),
],
persistedQueries: {
cache: new PrefixingKeyValueCache(
new InMemoryLRUCache({ maxSize: 1000 }),
"apq:"
),
ttl: 900, // 15 minutes
},
});
CDN Integration: With useGETForHashedQueries: true, persisted queries are
sent as GET requests, making them cachable by standard CDNs and edge caches.
7.2 Field-Level Caching
Use @cacheControl directives:
type Query {
user(id: ID!): User @cacheControl(maxAge: 60)
topPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC)
me: User @cacheControl(maxAge: 0, scope: PRIVATE)
}
type Post @cacheControl(maxAge: 600) {
id: ID!
title: String!
body: String! @cacheControl(maxAge: 3600)
viewCount: Int! @cacheControl(maxAge: 30)
}
7.3 Response Compression
import compression from "compression";
import express from "express";
const app = express();
app.use(compression()); // gzip/brotli for all responses
7.4 Batching & Defer/Stream
@defer (experimental) for incremental delivery:
query {
post(id: "42") {
title # Delivered immediately
author { name }
... on Post @defer {
body # Delivered in a later payload
comments {
body
author { name }
}
}
}
}
Enable with Apollo Server 4:
import { ApolloServer } from "@apollo/server";
import { buildSubgraphSchema } from "@apollo/subgraph";
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
// @defer support is built-in for federated schemas
});
7.5 Monitoring & Tracing
import { ApolloServerPluginInlineTrace } from "@apollo/server/plugin/inlineTrace";
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginInlineTrace({
includeErrors: { unmodified: true },
}),
],
});
Key metrics to track:
- Resolver execution time by field
- DataLoader batch sizes (are they actually batching?)
- Query parse/validation time vs execution time
- Error rate by operation
- Subscription connection churn
Safety Rules
ABSOLUTE RULES — never violate these:
-
Never expose internal database IDs as the node identifier in Relay patterns. Use opaque, globally unique IDs (base64-encoded
TypeName:UUID). Database IDs leak information about table sizes and insertion rate. -
Never disable introspection in a way that breaks developer tooling without providing an alternative. If you gate introspection, document how authenticated developers can access the schema.
-
Never return raw database errors to clients. Always map to typed GraphQL errors. Stack traces and SQL errors in production responses are information leaks.
-
Never create circular references in federation
@keychains. A → B → A entity resolution will cause infinite loops in the gateway. -
Never use
@shareablewithout coordination across subgraph teams. A@shareablefield with conflicting resolvers across subgraphs creates nondeterministic behavior. -
Never deploy without depth/cost limits. An unprotected GraphQL endpoint is a DoS vector. Minimum: depth limit = 7, maximum cost = 1000.
-
Never pass raw
req.bodyor unvalidated variables to resolvers. GraphQL argument coercion handles type validation, but business validation must be explicit. -
Never share DataLoader instances across requests. This causes data leaking between users and stale cache hits.
Platform Compatibility Notes
| Platform | Notes |
|---|---|
| Claude Code | Excellent for schema design iteration and resolver patterns. Use terminal access for npx graphql-codegen and schema composition. |
| Codex (OpenAI) | Strong at generating resolver implementations from schema definitions. Good at spotting N+1 patterns. |
| Cursor | Can read multiple schema/resolver files simultaneously. Ideal for cross-subgraph entity resolution validation. |
| Gemini CLI | Large context window aids full-schema review. Good for analyzing complex federated schemas end-to-end. |
| OpenClaw | Access to exec for running rover subgraph check and graphql-inspector. Use for CI/CD pipeline integration. |
| GitHub Copilot | Inline suggestions excel at resolver boilerplate and DataLoader patterns. Less effective at cross-file architectural review. |
| Windsurf | Multi-file workspace awareness helps with subgraph boundaries and entity cross-references. |
| OpenCode | Terminal-native: use for graphql-codegen setup, rover CLI operations, and schema composition in CI. |
Platform-Specific Adjustments
- If
roverCLI is unavailable: manually validate Federation directives and_service { sdl }output for each subgraph. - If
graphql-codegenis unavailable: manually verify TypeScript types against schema. Flag type mismatches as MAJOR findings. - If introspection is disabled: rely on schema SDL files. Verify that SDL files are in version control and match deployed schemas.
- For Discord/Slack delivery: use bullet lists, not tables. Split schema reviews across multiple messages if >10 findings.
References
references/graphql-patterns.md— Comprehensive GraphQL patterns and anti-patterns reference- GraphQL Spec (October 2021): https://spec.graphql.org/
- Apollo Federation Docs: https://www.apollographql.com/docs/federation/
- DataLoader: https://github.com/graphql/dataloader
- graphql-depth-limit: https://www.npmjs.com/package/graphql-depth-limit
- graphql-cost-analysis: https://www.npmjs.com/package/graphql-cost-analysis
- Apollo Server Security: https://www.apollographql.com/docs/apollo-server/security/
- Relay Pagination Spec: https://relay.dev/graphql/connections.htm
- graphql-rate-limit: https://www.npmjs.com/package/graphql-rate-limit
What ships with it: 5 files
79.6 KB alongside SKILL.md, 1 of them executable
evals/
- test_cases.json11.5 KB
references/
- graphql-patterns.md34.9 KB
scripts/
- validate_skill.pyruns29.9 KB
- CHANGELOG.md2.3 KB
- LICENSE1.0 KB