agentsclimarketplace

Graphql api development

Skill JPeetz/agent-skills/graphql-api-development

The definitive collection of cross-platform Agent Skills. Compatible with Claude Code, Codex, Cursor, OpenClaw, Gemini CLI, Copilot, Hermes. Curated weekly. Higher quality than any alternative.

Install
npx -y skills add JPeetz/agent-skills --skill graphql-api-development

Assembled 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.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

33.8 KB, 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

DimensionWhat to CheckKey Indicators
πŸ”΅ Schema DesignNaming, types, pagination, errorsVerb-first mutations, Relay pagination, union errors
🟠 ResolversData loading, context, error handlingDataLoader usage, null propagation, partial errors
⚑ Query OptimizationN+1 prevention, field selection, complexityBatched loads, DataLoader per request, query cost
🟣 MutationsInput types, idempotency, atomicitySingle input arg, idempotency keys, thin resolvers
πŸ“‘ SubscriptionsEvent sources, filtering, authAsyncIterator, withFilter, ws auth
πŸ—οΈ FederationEntity resolution, shared types, contracts@key directives, reference resolvers, contract tags
πŸ”’ SecurityAuthz, depth limiting, rate limitingField-level auth, graphql-depth-limit, persist-authenticated
πŸš€ PerformanceCaching, persisted queries, CDNAPQ, @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

  1. 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.

  2. Over-fetching in Resolvers β€” Resolvers returning all columns when the query only asks for id and name. Use field-aware database projections or parent-to-child delegation.

  3. 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.

  4. String-Based Error Handling β€” Returning null or magic strings for errors. Use typed error unions or the errors extensions payload so clients can pattern-match instead of string-parse.

  5. Monolithic Schema Before Federation β€” Building one massive schema and then retrofitting federation. Design with federation from the start: define entity boundaries, @key fields, and subgraph ownership.

  6. No Depth or Complexity Limits β€” Unbounded recursive queries can bring down a server. A single malicious query fetching user.posts.author.posts.author recursively is a DoS vector. Always set graphql-depth-limit or query cost analysis.

  7. Authentication in Resolvers, Not Middleware β€” Checking context.user inline 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.

  8. Subscription Leaks β€” AsyncIterators that never clean up lead to memory pressure. Every subscription source must have a proper teardown in the subscribe function's return { unsubscribe }.

  9. 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.

  10. Ignoring the extensions Field β€” 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, not UserCreate)
  • 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 @key directives and reference resolvers
  • Persisted queries are enabled for production builds
  • All types and fields have description strings
  • 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

ConstructConventionExample
TypesPascalCase, singular nounUser, Post, Payment
Query fieldscamelCase, noun or noun phraseuser(id:), searchPosts
MutationscamelCase, verb + objectcreatePost, cancelOrder
Input typesPascalCase, suffixed with InputCreatePostInput, UserFilter
Enum valuesUPPER_SNAKE_CASEOrderStatus.PENDING, PAYMENT_FAILED
Payload typesPascalCase, suffixed with PayloadCreatePostPayload, LoginPayload
Union errorsPascalCase, suffixed with ErrorValidationError, 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:

  1. Create new DataLoader instances per request β€” Never reuse across requests. Caching across requests causes stale data and security leaks.
  2. Return arrays in the same order as input keys β€” DataLoader matches by index. Wrong order = wrong data.
  3. Batch function must accept and return arrays β€” Single-item batch functions defeat the purpose.
  4. Handle nulls for not-found β€” Return null (not throw) for individual missing items so other items still resolve.
  5. Use DataLoader instance 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 Post data; the Users subgraph stores User data. Don't duplicate data across subgraphs.
  • Use @shareable for fields defined in multiple subgraphs.
  • Keep @key fields simple. Composite keys (@key(fields: "orgId userId")) are fine but avoid deeply nested keys.
  • @requires for 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 _entities queries 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:

  1. 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.

  2. 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.

  3. Never return raw database errors to clients. Always map to typed GraphQL errors. Stack traces and SQL errors in production responses are information leaks.

  4. Never create circular references in federation @key chains. A β†’ B β†’ A entity resolution will cause infinite loops in the gateway.

  5. Never use @shareable without coordination across subgraph teams. A @shareable field with conflicting resolvers across subgraphs creates nondeterministic behavior.

  6. Never deploy without depth/cost limits. An unprotected GraphQL endpoint is a DoS vector. Minimum: depth limit = 7, maximum cost = 1000.

  7. Never pass raw req.body or unvalidated variables to resolvers. GraphQL argument coercion handles type validation, but business validation must be explicit.

  8. Never share DataLoader instances across requests. This causes data leaking between users and stale cache hits.


Platform Compatibility Notes

PlatformNotes
Claude CodeExcellent 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.
CursorCan read multiple schema/resolver files simultaneously. Ideal for cross-subgraph entity resolution validation.
Gemini CLILarge context window aids full-schema review. Good for analyzing complex federated schemas end-to-end.
OpenClawAccess to exec for running rover subgraph check and graphql-inspector. Use for CI/CD pipeline integration.
GitHub CopilotInline suggestions excel at resolver boilerplate and DataLoader patterns. Less effective at cross-file architectural review.
WindsurfMulti-file workspace awareness helps with subgraph boundaries and entity cross-references.
OpenCodeTerminal-native: use for graphql-codegen setup, rover CLI operations, and schema composition in CI.

Platform-Specific Adjustments

  • If rover CLI is unavailable: manually validate Federation directives and _service { sdl } output for each subgraph.
  • If graphql-codegen is 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

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.