| name | graphql-development |
| description | GraphQL API development including schema design, resolvers, N+1 prevention, subscriptions, and federation. Use when building GraphQL APIs, optimizing query performance, or implementing real-time features. Use when this capability is needed. |
| metadata | {"author":"bendourthe"} |
GraphQL Development
Comprehensive guidance on building production-quality GraphQL APIs, covering schema design, resolver implementation, the N+1 problem with DataLoader, subscriptions, Apollo Federation, caching strategies, security, and client-side patterns.
When to Use This Skill
Use this skill for:
- Designing GraphQL schemas (types, interfaces, unions, enums)
- Implementing resolvers with batching and caching
- Preventing N+1 query problems with DataLoader
- Adding real-time features with subscriptions
- Implementing pagination (Relay cursor, offset-based)
- Securing GraphQL APIs (authentication, authorization, query complexity)
- Setting up Apollo Federation for microservices
- Optimizing client-side data fetching (Apollo Client, urql)
Trigger phrases: "GraphQL", "schema", "resolver", "DataLoader", "subscription", "mutation", "query complexity", "N+1", "federation", "Apollo", "SDL", "GraphQL API"
What This Skill Does
Provides production-ready GraphQL patterns including:
- Schema Design: Types, interfaces, unions, enums, input types, custom scalars
- Resolvers: Field-level resolution, context injection, error handling
- Performance: DataLoader for batching, query complexity analysis, persisted queries
- Real-Time: Subscriptions via WebSocket, pub/sub patterns
- Federation: Apollo Federation gateway, subgraph design, entity resolution
- Security: Authentication, field-level authorization, depth limiting, rate limiting
- Clients: Apollo Client, urql, cache normalization
Instructions
Step 1: Design the Schema
Schema Design Principles:
1. Design for the client's needs, not the database schema
2. Use nullable fields by default; make non-null only when guaranteed
3. Prefer specific types over generic ones
4. Use interfaces for shared fields, unions for polymorphic returns
5. Suffix input types with "Input", payloads with "Payload"
6. Always return the modified object in mutation payloads
Complete Schema Example (SDL):
scalar DateTime
scalar EmailAddress
scalar URL
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
enum SortDirection {
ASC
DESC
}
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
email: EmailAddress!
name: String!
avatar URL
orders Int, String, OrderStatus OrderConnection
DateTime
DateTime
Product implements Node & Timestamped
ID
String
String
Float
Category
URL
Boolean
reviews Int, String ReviewConnection
Float
DateTime
DateTime
Order implements Node & Timestamped
ID
User
OrderItem
OrderStatus
Float
Address
DateTime
DateTime
OrderItem
Product
Int
Float
Float
Address
String
String
String
String
String
Category implements Node
ID
String
String
products Int, String ProductConnection
Review implements Node & Timestamped
ID
User
Product
Int
String
DateTime
DateTime
PageInfo
Boolean
Boolean
String
String
ProductConnection
ProductEdge
PageInfo
Int
ProductEdge
Product
String
OrderConnection
OrderEdge
PageInfo
Int
OrderEdge
Order
String
ReviewConnection
ReviewEdge
PageInfo
Int
ReviewEdge
Review
String
CreateOrderInput
OrderItemInput
AddressInput
OrderItemInput
ID
Int
AddressInput
String
String
String
String
String
ProductFilterInput
ID
Float
Float
Boolean
String
ProductSortInput
ProductSortField
SortDirection
ProductSortField
NAME
PRICE
CREATED_AT
RATING
CreateOrderPayload
Order
UserError
UserError
String
String
String
user ID User
product ID Product
order ID Order
products
ProductFilterInput
ProductSortInput
Int
String
ProductConnection
User
createOrder CreateOrderInput CreateOrderPayload
cancelOrder ID CreateOrderPayload
addReview ID, Int, String Review
orderStatusChanged ID Order
newReview ID Review
Step 2: Implement Resolvers (Node.js)
Resolver Structure with Context:
const { GraphQLDateTime } = require("graphql-scalars");
const resolvers = {
DateTime: GraphQLDateTime,
Query: {
viewer: (_parent, _args, context) => {
if (!context.user) return null;
return context.dataSources.users.getById(context.user.id);
},
product: (_parent, { id }, context) => {
return context.dataSources.products.getById(id);
},
products: (_parent, { filter, sort, first = 20, after }, context) => {
return context.dataSources.products.getConnection({
filter,
sort,
first: Math.min(first, 100),
after,
});
},
},
Mutation: {
createOrder: async (_parent, { input }, context) => {
if (!context.user) {
{
: ,
: [{ : , : }],
};
}
{
order = context...(context.., input);
context..(, { : order });
{ order, : [] };
} (error) {
{
: ,
: [{ : error., : , : error. }],
};
}
},
},
: {
: {
context...(user., { first, after, status });
},
},
: {
: {
context...(product.);
},
: {
context...(product., { first, after });
},
: {
context...(product.);
},
},
: {
: {
context...(order.);
},
: {
context...(order.);
},
},
: {
: {
context...(item.);
},
},
: {
: {
: {
(!context.) ();
context..();
},
},
: {
: {
context..();
},
},
},
};
Step 3: Solve the N+1 Problem with DataLoader
DataLoader Setup:
const DataLoader = require("dataloader");
function createLoaders(db) {
return {
userById: new DataLoader(async (ids) => {
const users = await db.query(
"SELECT * FROM users WHERE id = ANY($1)",
[ids]
);
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) || null);
}),
productById: new DataLoader(async (ids) => {
const products = await db.query(
"SELECT * FROM products WHERE id = ANY($1)",
[ids]
);
const map = new Map(products.map((p) => [p.id, p]));
return ids.( map.(id) || );
}),
: ( (ids) => {
categories = db.(
,
[ids]
);
map = (categories.( [c., c]));
ids.( map.(id) || );
}),
: ( (userIds) => {
orders = db.(
,
[userIds]
);
grouped = ();
( order orders) {
list = grouped.(order.) || [];
list.(order);
grouped.(order., list);
}
userIds.( grouped.(id) || []);
}),
};
}
server = ({
typeDefs,
resolvers,
: ({
: req.,
: (db),
}),
});
Python DataLoader (Strawberry + aiodataloader):
from aiodataloader import DataLoader
from typing import list, Optional
class UserLoader(DataLoader):
async def batch_load_fn(self, user_ids: list[str]) -> list[Optional[dict]]:
"""Load multiple users in a single query."""
users = await db.fetch_all(
"SELECT * FROM users WHERE id = ANY(:ids)",
{"ids": user_ids},
)
user_map = {u["id"]: u for u in users}
return [user_map.get(uid) for uid in user_ids]
class ProductLoader(DataLoader):
async def batch_load_fn(self, product_ids: list[str]) -> list[Optional[dict]]:
products = await db.fetch_all(
"SELECT * FROM products WHERE id = ANY(:ids)",
{"ids": product_ids},
)
product_map = {p["id"]: p for p in products}
return [product_map.get(pid) for pid product_ids]
():
info.context[].user.load(order.user_id)
Step 4: Implement Subscriptions
WebSocket Subscription Server (Node.js):
const { createServer } = require("http");
const { WebSocketServer } = require("ws");
const { useServer } = require("graphql-ws/lib/use/ws");
const { ApolloServer } = require("@apollo/server");
const { expressMiddleware } = require("@apollo/server/express4");
const { makeExecutableSchema } = require("@graphql-tools/schema");
const { PubSub } = require("graphql-subscriptions");
const express = require("express");
const pubsub = new PubSub();
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: "/graphql",
});
const serverCleanup = useServer(
{
schema,
context: async (ctx) => {
token = ctx.?.;
user = token ? (token) : ;
{ user, pubsub };
},
: (ctx) => {
.();
},
: {
.();
},
},
wsServer
);
server = ({
schema,
: [
{
() {
{
() {
serverCleanup.();
},
};
},
},
],
});
server.();
app.(, (server));
httpServer.();
Step 5: Secure the API
Authentication and Authorization:
const { mapSchema, getDirective, MapperKind } = require("@graphql-tools/utils");
const { defaultFieldResolver } = require("graphql");
function authDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, "auth")?.[0];
if (!authDirective) return fieldConfig;
const { requires: role } = authDirective;
const originalResolve = fieldConfig.resolve || defaultFieldResolver;
fieldConfig.resolve = async function (source, args, context, info) {
if (!context.user) {
throw new Error("Authentication required");
}
if (role && !context.user.roles.includes(role)) {
throw new Error(`Role '${role}' required`);
}
return originalResolve(source, args, context, info);
};
fieldConfig;
},
});
}
Query Complexity and Depth Limiting:
const { createComplexityLimitRule } = require("graphql-validation-complexity");
const depthLimit = require("graphql-depth-limit");
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
formatErrorMessage: (cost) =>
`Query complexity ${cost} exceeds maximum of 1000`,
}),
],
});
Persisted Queries (Automatic):
const { ApolloServer } = require("@apollo/server");
const {
ApolloServerPluginPersistedQueries,
} = require("@apollo/server/plugin/persistedQueries");
const { KeyvAdapter } = require("@apollo/utils.keyvadapter");
const Keyv = require("keyv");
const server = new ApolloServer({
schema,
plugins: [
ApolloServerPluginPersistedQueries({
cache: new KeyvAdapter(new Keyv("redis://localhost:6379")),
ttl: 86400,
}),
],
});
Step 6: Set Up Apollo Federation
Subgraph Definition (Products Service):
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable"])
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
category: Category!
inStock: Boolean!
}
type Category @key(fields: "id") {
id: ID!
name: String!
products(first: Int, after: String) ProductConnection
product ID Product
products ProductFilterInput, Int, String ProductConnection
Subgraph Definition (Orders Service):
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])
type Order @key(fields: "id") {
id: ID!
user: User!
items: [OrderItem!]!
status: OrderStatus!
total: Float!
}
type User @key(fields: "id") {
id: ID! @external
orders(first: Int, String OrderConnection
OrderItem
Product
Int
Float
Product
ID
Apollo Router Configuration:
supergraph:
listen: 0.0.0.0:4000
subgraphs:
products:
routing_url: http://products-service:4001/graphql
orders:
routing_url: http://orders-service:4002/graphql
users:
routing_url: http://users-service:4003/graphql
traffic_shaping:
all:
timeout: 30s
subgraphs:
products:
timeout: 10s
telemetry:
instrumentation:
spans:
mode: spec_compliant
exporters:
tracing:
otlp:
endpoint: http://otel-collector:4317
Best Practices
- Design schemas for clients, not for the database; think in terms of UI components
- Use DataLoader for every relationship resolver to prevent N+1 queries
- Return mutation payloads (not bare types) so errors can be communicated in-band
- Implement cursor-based pagination (Relay spec) for stable, efficient paging
- Set query depth and complexity limits to prevent abuse and denial-of-service
- Use persisted queries in production to reduce payload size and prevent arbitrary query injection
- Create fresh DataLoader instances per request; their cache is request-scoped
- Keep resolvers thin; delegate business logic to service/data layers
- Use subscriptions only for data the client is actively viewing; not for background sync
- Version schemas additively; deprecate fields with
@deprecated instead of removing them
Common Patterns
Pattern 1: Relay Cursor Pagination Implementation
function buildConnection(rows, hasMore, getCursor) {
const edges = rows.map((row) => ({
node: row,
cursor: getCursor(row),
}));
return {
edges,
pageInfo: {
hasNextPage: hasMore,
hasPreviousPage: false,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
totalCount: null,
};
}
Pattern 2: Error Union Pattern
union CreateOrderResult = Order | ValidationError | InsufficientStockError
type ValidationError {
field: String!
message: String!
}
type InsufficientStockError {
productId: ID!
requested: Int!
available: Int!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderResult!
}
Pattern 3: Viewer Pattern for Auth Context
type Query {
viewer: User
}
type User {
email: EmailAddress!
orders: OrderConnection!
cart: Cart!
}
Common Rationalizations
| Rationalization | Reality |
|---|
| "The resolver works, I'll add DataLoader later" | A relationship resolver without DataLoader is an N+1 query that issues one DB call per row; "later" arrives as a production latency spike when the list grows from 10 to 10,000 rows. |
| "We don't need a depth limit, our clients are trusted" | GraphQL exposes a recursive query surface; without a depth and complexity limit a single deeply-nested query (or a malicious one) can fan out into a denial-of-service against the database. |
| "Mutations can just throw, the client will handle it" | Throwing for expected user errors collapses them into transport-level failures; payload types with an errors field let the client distinguish validation failure from a 500, which a thrown exception erases. |
| "Auth at the route is enough for GraphQL" | A single endpoint serves every field; route-level auth cannot protect a sensitive field reachable through an alternate query path, so authorization must be applied at the field/resolver layer. |
Verification
Related Skills
- [[api-documentation]] -- documents the GraphQL schema with descriptions and examples
- [[performance-testing]] -- load-tests GraphQL endpoints to catch N+1 and complexity regressions
- [[async-patterns]] -- the subscription and real-time concurrency patterns GraphQL subscriptions rely on
- [[security-review]] -- assesses GraphQL-specific attack surface (depth abuse, introspection, field-level auth)
Version: 1.0.0
Last Updated: March 2026
Iterative Refinement Strategy
This skill is optimized for an iterative approach:
- Execute: Perform the core steps defined above.
- Review: Critically analyze the output (coverage, quality, completeness).
- Refine: If targets aren't met, repeat the specific implementation steps with improved context.
- Loop: Continue until the definition of done is satisfied.
Source: bendourthe/Nexus-Hub — distributed by TomeVault.