kid-sid/claude-spellbook/graphql

Claude Code and agent skill by kid-sid/claude-spellbook

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install kid-sid/claude-spellbook/graphql

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
graphql
# 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
graphql
# 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.

graphql
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
# 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
// 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
# 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
// 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

graphql
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusEvent!
}
type OrderStatusEvent {
orderId: ID!
status: OrderStatus!
updatedAt: DateTime!
}
typescript
// 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:

graphql
# 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
// 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
typescript
const server = new ApolloServer({
introspection: process.env.NODE_ENV !== "production",
});
Field-Level Authorization
python
# 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
graphql
# 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

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
186
Repository forks
15
7-day trend score
26

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/kid-sid/claude-spellbook/graphql/badge)](https://verified-skill.com/skills/kid-sid/claude-spellbook/graphql)

Something wrong with this skill or its page? Tell us.