Skills

Install

$ npx ai-agents-skills add --skill graphql
Domain v1.0

GraphQL

Patterns for schema design, resolver implementation, N+1 prevention, and client-side queries. Applies to Apollo Server, Pothos, graphql-yoga, and Apollo Client.

When to Use

  • Designing a GraphQL schema
  • Implementing resolvers on the server
  • Writing queries, mutations, or fragments on the client
  • Diagnosing N+1 query problems or over-fetching

Don’t use for:

  • REST API design (use express or hono)
  • tRPC (type-safe RPC, different paradigm)
  • Database query optimization (use relevant ORM skill)

Critical Patterns

✅ REQUIRED [CRITICAL]: DataLoader for N+1 Prevention

Every resolver that fetches by a foreign key must use DataLoader. Without it, each parent record triggers a separate DB query.

// ❌ WRONG — 1 query per user (N+1)
const resolvers = {
  Post: {
    author: (post) => db.user.findUnique({ where: { id: post.authorId } }),
  },
};

// ✅ CORRECT — batches all authorIds into one query per request
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (ids: readonly string[]) => {
  const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
  return ids.map((id) => users.find((u) => u.id === id) ?? null);
});

const resolvers = {
  Post: { author: (post) => userLoader.load(post.authorId) },
};

✅ REQUIRED [CRITICAL]: Schema-First or Code-First — Pick One

Mixing SDL strings with programmatic schema construction produces unmaintainable schemas.

// ✅ CORRECT — schema-first (SDL with graphql-tag or .graphql files)
const typeDefs = gql`
  type User { id: ID! name: String! posts: [Post!]! }
  type Post { id: ID! title: String! author: User! }
  type Query { user(id: ID!): User post(id: ID!): Post }
`;

// ✅ CORRECT — code-first (Pothos or NestJS GraphQL decorators — consistent throughout)
// ❌ WRONG — SDL strings for some types, programmatic for others in same project

✅ REQUIRED: Nullable vs Non-Null Discipline

! (non-null) means the API contract guarantees a value. A null where ! was declared crashes the client.

# ❌ WRONG — over-asserting non-null; any DB miss crashes the query tree
type Post {
  author: User!   # what if user was deleted?
}

# ✅ CORRECT — nullable for optional or potentially-missing relations
type Post {
  author: User    # null handled gracefully on client
}

# Non-null only for fields that are structurally guaranteed
type User {
  id: ID!         # always exists
  email: String!  # required at registration
}

✅ REQUIRED: Pagination — Cursor-Based over Offset

Offset pagination breaks on concurrent inserts/deletes. Cursor-based is stable.

# ❌ WRONG — offset pagination (page/limit)
posts(page: Int, limit: Int): [Post!]!

# ✅ CORRECT — Relay-style cursor pagination
posts(first: Int, after: String): PostConnection!

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}
type PostEdge { node: Post! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }

✅ REQUIRED: Fragments for Client-Side Reuse

Duplicate field selections across queries create maintenance drift.

# ❌ WRONG — field selection duplicated in every query
query UserProfile { user(id: $id) { id name avatar email createdAt } }
query UserCard     { user(id: $id) { id name avatar } }

# ✅ CORRECT — shared fragment
fragment UserFields on User { id name avatar }
query UserProfile { user(id: $id) { ...UserFields email createdAt } }
query UserCard     { user(id: $id) { ...UserFields } }

❌ NEVER: Expose Internal IDs Directly

Auto-increment DB IDs leak record counts and enable enumeration attacks.

# ❌ WRONG — exposes sequential integer IDs
type User { id: Int! }

# ✅ CORRECT — opaque global IDs (UUID or encoded cursor)
type User { id: ID! }  # resolves to UUID or base64("User:123")

✅ REQUIRED: Depth and Complexity Limits

Recursive queries can overload the server. Apply query depth and complexity limits.

// ✅ CORRECT — protect against deeply nested or complex queries
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  validationRules: [
    depthLimit(7),
    createComplexityLimitRule(1000),
  ],
});

Decision Tree

Resolver fetches by a foreign key (authorId, userId, etc.)?
  → DataLoader required — never direct DB call per parent record

Choosing schema approach?
  → Small team, explicit SDL preferred → schema-first (.graphql files)
  → TypeScript-heavy, type safety priority → code-first (Pothos)
  → Never mix both in the same project

Field nullable or non-null?
  → Structurally guaranteed (PK, required at creation) → non-null (!)
  → Relation that could be deleted or optional → nullable

Listing resources?
  → Need stable pagination under concurrent writes → cursor-based (Relay spec)
  → Simple admin UI, low-traffic → offset acceptable

Client query uses same fields in multiple places?
  → Extract to fragment

API publicly accessible?
  → Add depth limit (7) and complexity limit

API private or schema is sensitive?
  → Disable introspection in production
  → Public APIs (documented, third-party clients): keep introspection enabled

Mutation returns updated data?
  → Return the mutated object — never just a boolean
  → Include affected relations the client likely needs to refetch
  → Domain errors (validation, conflict)? → union return type preferred over throw
     Example: CreateUserResult = User | ValidationError | ConflictError

Example

Server with DataLoader and schema-first approach, client with fragments.

// Server — schema-first + DataLoader
const typeDefs = gql`
  type User { id: ID! name: String! posts: [Post!]! }
  type Post { id: ID! title: String! author: User }
  type Query { posts(first: Int, after: String): PostConnection! }
`;

const createLoaders = () => ({
  user: new DataLoader(async (ids: readonly string[]) => {
    const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
    return ids.map((id) => users.find((u) => u.id === id) ?? null);
  }),
});

const resolvers = {
  Post: {
    author: (post, _, { loaders }) => loaders.user.load(post.authorId),
  },
};

// Per-request loader context — loaders must NOT be shared across requests
const server = new ApolloServer({
  typeDefs, resolvers,
  context: () => ({ loaders: createLoaders() }),
});
# Client — fragments prevent field duplication
fragment PostSummary on Post {
  id
  title
  author { id name }
}

query FeedPosts($after: String) {
  posts(first: 20, after: $after) {
    edges { node { ...PostSummary } cursor }
    pageInfo { hasNextPage endCursor }
  }
}

Edge Cases

DataLoader per request: DataLoader caches by key within a request. It MUST be instantiated per request — a shared DataLoader across requests leaks data between users.

Subscription authorization: Subscriptions open a persistent WebSocket. Validate auth token on connection (onConnect), not on each message — and close the connection on token expiry.

File uploads: Multipart form uploads via graphql-upload conflict with some middleware. Consider pre-signed URL pattern instead: mutation returns a signed S3 URL, client uploads directly.

Federation (multiple subgraphs): Use Apollo Federation if you need to split the schema across services. Each subgraph owns its types; the gateway stitches them. Don’t implement manual schema stitching — it’s unmaintainable at scale.