agentleFS
kid-sidkid-sid/claude-spellbook/skills/graphql/skill.md

graphql

Skill190 starsChanged 6 months agoMITView on GitHub

Use when designing a GraphQL schema, implementing resolvers or mutations, solving the N+1 query problem with DataLoader, setting up subscriptions, paginating with Cursor Connections, securing a GraphQL API, or choosing between GraphQL and REST.

  • Reads credentials

We split this skill into the rules it gives an agent

2 rules. 0 are common, 2 are distinctive, and 0 are very specific to this project.

  • 0

    Common

    Most of the 18 edited versions we read keep these.

  • 2

    Distinctive

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use GraphQL when you have multiple clients (web, mobile, third-party) with different data needs.

    line 33
  2. Distinctive:

    Use the Relay Cursor Connection spec for all list fields — it handles forward, backward, and arbitrary pagination consistently.

    line 122

“Common” is measured: kept by at least 6 in 10 of the 18 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
skill.md

GraphQL

Schema design, resolver patterns, performance, and security for production GraphQL APIs.

When to Activate

  • Designing types, queries, mutations, or subscriptions in a GraphQL schema
  • Implementing resolvers in Python (Strawberry/Ariadne), TypeScript (Apollo/Pothos), or Go (gqlgen)
  • Solving N+1 query problems with DataLoader
  • Paginating results with Cursor Connections
  • Securing a GraphQL endpoint against introspection, depth attacks, or query abuse
  • Choosing between GraphQL and REST for a new API
  • Setting up real-time updates with subscriptions

GraphQL vs. REST

ConcernGraphQLREST
Data fetchingClient specifies exact fieldsServer defines response shape
Multiple resourcesSingle requestOne request per resource
VersioningSchema evolves via deprecationURL or header versioning
CachingComplex (query-level)Simple (HTTP cache headers)
File uploadsNon-standardNative multipart
Best forFlexible client needs, multiple consumersSimple CRUD, public APIs, CDN caching

Use GraphQL when you have multiple clients (web, mobile, third-party) with different data needs. Prefer REST for simple CRUD with aggressive HTTP caching.


Schema Design

Type Conventions

# Scalar types
scalar DateTime   # ISO-8601 string
scalar UUID
scalar JSON

# Object type — PascalCase, fields camelCase
type User {
  id: ID!
  email: String!
  createdAt: DateTime!
  orders(first: Int, after: String): OrderConnection!
}

# Input type — suffix with Input
input CreateUserInput {
  email: String!
  name: String!
}

# Enum — SCREAMING_SNAKE_CASE values
enum OrderStatus {
  PENDING
  PROCESSING
  COMPLETED
  CANCELLED
}

# Interface — shared fields across types
interface Node {
  id: ID!
}

# Union — one of several types
union SearchResult = User | Product | Order

Nullable vs. Non-Null

PatternSchemaWhen to use
Always presentfield: String!Required data — fetch fails if missing
Optionalfield: StringMay legitimately be absent
List always presentitems: [Item!]!List itself and items always exist
List may be absentitems: [Item!]Null means "not loaded", [] means "empty"

Prefer non-null (!) for fields that are always present. Nullable fields force every client to null-check; use them only when absence is meaningful.

Mutations

# BAD — returns the raw type
type Mutation {
  createUser(email: String!, name: String!): User
}

# GOOD — dedicated payload type with errors
type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
}

type CreateUserPayload {
  user: User          # null on failure
  errors: [UserError!]!
}

type UserError {
  field: String       # null for non-field errors
  message: String!
  code: String!
}

Mutation payload types give clients a typed error path without relying on the errors top-level array.


Cursor Connections (Pagination)

Use the Relay Cursor Connection spec for all list fields — it handles forward, backward, and arbitrary pagination consistently.

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}
# Python — cursor is base64(type:id)
import base64

def encode_cursor(type_name: str, id: str) -> str:
    return base64.b64encode(f"{type_name}:{id}".encode()).decode()

def decode_cursor(cursor: str) -> tuple[str, str]:
    decoded = base64.b64decode(cursor.encode()).decode()
    type_name, id = decoded.split(":", 1)
    return type_name, id

Resolvers and the N+1 Problem

The Problem

Query: { users { id orders { id total } } }

Naive resolver:
  SELECT * FROM users                    — 1 query
  SELECT * FROM orders WHERE user_id=1   — 1 query per user
  SELECT * FROM orders WHERE user_id=2
  SELECT * FROM orders WHERE user_id=3   — N queries for N users = N+1 total

DataLoader (Batch + Cache)

// TypeScript — Apollo Server with DataLoader
import DataLoader from "dataloader";

// One loader per request — never share across requests
function createLoaders() {
  return {
    ordersByUserId: new DataLoader<string, Order[]>(async (userIds) => {
      const orders = await db.order.findMany({
        where: { userId: { in: [...userIds] } },
      });
      // Return results in the same order as keys
      return userIds.map((id) => orders.filter((o) => o.userId === id));
    }),
  };
}

// Resolver
const resolvers = {
  User: {
    orders: (user, _args, { loaders }) =>
      loaders.ordersByUserId.load(user.id), // batched automatically
  },
};
# Python — Strawberry with strawberry-django DataLoader
from strawberry.dataloader import DataLoader

async def load_orders(user_ids: list[str]) -> list[list[Order]]:
    orders = await Order.objects.filter(user_id__in=user_ids).all()
    mapping: dict[str, list[Order]] = {id: [] for id in user_ids}
    for order in orders:
        mapping[order.user_id].append(order)
    return [mapping[id] for id in user_ids]

orders_loader = DataLoader(load_fn=load_orders)
// Go — gqlgen with graph-gophers/dataloader
loader := dataloader.NewBatchedLoader(func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
    ids := make([]string, len(keys))
    for i, k := range keys { ids[i] = k.String() }

    orders, _ := db.FindOrdersByUserIDs(ctx, ids)

    // Map results back to key order
    results := make([]*dataloader.Result, len(keys))
    orderMap := groupByUserID(orders)
    for i, k := range keys {
        results[i] = &dataloader.Result{Data: orderMap[k.String()]}
    }
    return results
})

Subscriptions

type Subscription {
  orderStatusChanged(orderId: ID!): OrderStatusEvent!
}

type OrderStatusEvent {
  orderId: ID!
  status: OrderStatus!
  updatedAt: DateTime!
}
// TypeScript — Apollo Server with Redis PubSub
import { RedisPubSub } from "graphql-redis-subscriptions";

const pubsub = new RedisPubSub({
  publisher: new Redis(process.env.REDIS_URL),
  subscriber: new Redis(process.env.REDIS_URL),
});

const resolvers = {
  Subscription: {
    orderStatusChanged: {
      subscribe: (_root, { orderId }) =>
        pubsub.asyncIterableIterator(`ORDER_STATUS:${orderId}`),
    },
  },
  Mutation: {
    updateOrderStatus: async (_root, { orderId, status }) => {
      const order = await db.order.update({ where: { id: orderId }, data: { status } });
      await pubsub.publish(`ORDER_STATUS:${orderId}`, { orderStatusChanged: order });
      return order;
    },
  },
};

Security

Query Complexity and Depth Limits

Without limits, a single query can exhaust server resources:

# Depth attack
{ user { friends { friends { friends { friends { id } } } } } }

# Breadth attack — requests thousands of fields
{ users(first: 1000) { orders(first: 1000) { items(first: 1000) { id } } } }
// TypeScript — graphql-depth-limit + graphql-query-complexity
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-query-complexity";

const server = new ApolloServer({
  validationRules: [
    depthLimit(5),
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
});

Disable Introspection in Production

const server = new ApolloServer({
  introspection: process.env.NODE_ENV !== "production",
});

Field-Level Authorization

# Python — Strawberry permission classes
import strawberry
from strawberry.permission import BasePermission

class IsAuthenticated(BasePermission):
    message = "Not authenticated"
    def has_permission(self, source, info, **kwargs) -> bool:
        return info.context.user is not None

class IsAdmin(BasePermission):
    message = "Admin access required"
    def has_permission(self, source, info, **kwargs) -> bool:
        return getattr(info.context.user, "role", None) == "admin"

@strawberry.type
class Query:
    @strawberry.field(permission_classes=[IsAuthenticated])
    def me(self, info) -> User:
        return info.context.user

    @strawberry.field(permission_classes=[IsAdmin])
    def all_users(self, info) -> list[User]:
        return User.objects.all()

Schema Evolution

ChangeSafe?Notes
Add a fieldYesExisting clients ignore unknown fields
Add a typeYesNot exposed until a query uses it
Add a non-null argumentNoBreaks clients not passing the argument
Add an optional argumentYesDefault value required
Remove or rename a fieldNoDeprecate first, remove after migration
Change field typeNoAlways breaking
# Deprecate before removing — give clients time to migrate
type User {
  name: String @deprecated(reason: "Use `firstName` and `lastName` instead")
  firstName: String!
  lastName: String!
}

Red Flags

  • Returning raw errors in the errors array for business failures — use mutation payload types with a typed errors field; the top-level errors array is for server errors only.
  • No DataLoader for nested list resolvers — every list field that loads related data without batching causes N+1 queries; instrument with query logging to catch them.
  • Introspection enabled in production — exposes your full schema to attackers; disable it or restrict to authenticated users.
  • No depth or complexity limits — a deeply nested query can exhaust CPU and memory; always set limits in validation rules.
  • Nullable everything — excessive nullability forces clients to null-check every field; use ! for fields that are always present.
  • Business logic in resolvers — resolvers become untestable and duplicated; keep resolvers thin and delegate to a service layer.
  • Sharing DataLoader instances across requests — DataLoaders cache per-request; a shared loader leaks data between users.
  • One mutation per field — updateUserName, updateUserEmail as separate mutations is a smell; use updateUser(input: UpdateUserInput!) with partial input.

Checklist

  • All list fields use Cursor Connection pagination — no offset-based skip/limit
  • Mutations return dedicated payload types with a typed errors field
  • DataLoader used for every resolver that loads related entities — no N+1 queries
  • Query depth limit set (max 5–7 levels)
  • Query complexity limit set and tuned to realistic usage
  • Introspection disabled in production
  • Field-level authorization applied — not just route-level auth middleware
  • Non-null (!) used for fields that are always present — nullable only where absence is meaningful
  • Deprecated fields annotated with @deprecated(reason: "...") before removal
  • DataLoader instances created per-request — never shared across requests
  • Subscriptions use a pub/sub backend (Redis) — not in-memory for multi-instance deployments
  • Schema linted with graphql-inspector or equivalent in CI

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.