engineering/api-design/skills/graphql-api-design/SKILL.md
This skill should be used when the user asks about "GraphQL", "GraphQL schema", "GraphQL query", "GraphQL mutation", "GraphQL subscription", "resolvers", "type definitions", "SDL", "schema definition language", "DataLoader", "N+1 in GraphQL", "GraphQL pagination", "GraphQL fragments", "directives", "federation", "Apollo", "Relay", "schema stitching", "schema federation", "persisted queries", or "GraphQL vs REST". Also trigger for "how do I design this GraphQL type", "how to batch GraphQL requests", "why are my GraphQL queries slow", or "how to paginate in GraphQL".
npx skillsauth add harsh040506/claude-code-unified-skill-plugin-library graphql-api-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Production-quality patterns for GraphQL schema design, resolver implementation, and performance.
GraphQL is about the data consumers need, not the database schema. The two should often diverge.
# Bad: Leaking database schema into the API
type orders_table {
order_id: Int!
customer_fk: Int!
order_status_enum: String!
created_timestamp: Int! # Unix epoch — hard for consumers
}
# Good: Consumer-friendly API
type Order {
id: ID!
status: OrderStatus!
customer: Customer! # Resolved via join or DataLoader, not FK
createdAt: DateTime! # ISO 8601, not epoch
lineItems: [LineItem!]!
totalAmount: Money! # Custom scalar, not raw integer
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Types: PascalCase — User, OrderLineItem, ShippingAddress
Fields: camelCase — firstName, createdAt, totalAmountCents
Enums: PascalCase type — OrderStatus, PaymentMethod
Enum values: ALL_CAPS_UNDERSCORE — PENDING, ON_HOLD, PARTIALLY_REFUNDED
Mutations: verb + Noun — createOrder, updateUser, cancelSubscription
Inputs: PascalCase + Input — CreateOrderInput, UpdateUserInput
# Use ! (non-null) when the value is guaranteed and meaningful
type User {
id: ID! # Always present — use !
email: String! # Always present — use !
firstName: String! # Always present — use !
avatar: String # Optional — may be null, no !
deletedAt: DateTime # Null = not deleted, no !
}
# Return null === "doesn't exist"
# Throw error === "something went wrong trying to get it"
# Never return null to mean "error occurred"
# Custom scalars
scalar DateTime # ISO 8601 string
scalar UUID # UUID v4 string
scalar JSON # Arbitrary JSON value (use sparingly)
# Pagination (Relay Connection Spec — widely supported)
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Generic connection types (repeat per type)
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
# Core types
type Order {
id: ID!
status: OrderStatus!
customer: Customer!
lineItems: [LineItem!]!
subtotalCents: Int!
taxCents: Int!
totalCents: Int!
shippingAddress: Address!
createdAt: DateTime!
updatedAt: DateTime!
cancelledAt: DateTime
cancelReason: String
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
# Queries
type Query {
order(id: ID!): Order # null = not found; error = system failure
orders(
first: Int
after: String
last: Int
before: String
filter: OrderFilter
orderBy: OrderOrderBy
): OrderConnection!
currentUser: User # null = not authenticated
}
# Mutations
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}
# Mutation inputs
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddressId: ID!
paymentMethodId: ID!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
# Mutation payloads (always return the affected resource + errors)
type CreateOrderPayload {
order: Order # null if creation failed
userErrors: [UserError!]! # Empty array = success
}
type UserError {
message: String!
field: [String!]! # Path to the field that failed (e.g., ["items", "0", "quantity"])
code: String! # Machine-readable code
}
# Filters and ordering
input OrderFilter {
status: OrderStatus
createdAfter: DateTime
createdBefore: DateTime
customerId: ID
}
enum OrderOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
TOTAL_CENTS_ASC
TOTAL_CENTS_DESC
}
# Subscriptions (for real-time updates)
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusChangedPayload!
}
type OrderStatusChangedPayload {
order: Order!
previousStatus: OrderStatus!
}
// TypeScript resolver with DataLoader for N+1 prevention
import DataLoader from 'dataloader';
// Resolver map matches schema shape
const resolvers = {
Query: {
order: async (_parent, { id }, ctx) => {
// Authorization check before data access
if (!ctx.user) throw new AuthenticationError('Must be logged in');
const order = await ctx.db.orders.findById(id);
// Return null for not found — GraphQL spec allows this
if (!order) return null;
// Authorization: can this user see this order?
if (order.customerId !== ctx.user.id && !ctx.user.isAdmin) {
throw new ForbiddenError('Not authorized to view this order');
}
return order;
},
orders: async (_parent, args, ctx) => {
if (!ctx.user) throw new AuthenticationError('Must be logged in');
const { first = 20, after, filter, orderBy } = args;
// Relay cursor pagination
const [items, totalCount] = await Promise.all([
ctx.db.orders.findMany({
where: buildWhereClause(filter, ctx.user),
orderBy: buildOrderBy(orderBy),
cursor: after ? decodeCursor(after) : undefined,
take: first + 1, // Request one extra to check hasNextPage
}),
ctx.db.orders.count({ where: buildWhereClause(filter, ctx.user) }),
]);
const hasNextPage = items.length > first;
const edges = items.slice(0, first).map(item => ({
node: item,
cursor: encodeCursor(item),
}));
return {
edges,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
},
// Object type resolver — handles nested fields
Order: {
// customer will be called for every Order in a list — use DataLoader!
customer: async (order, _args, ctx) => {
return ctx.loaders.user.load(order.customerId);
},
lineItems: async (order, _args, ctx) => {
return ctx.loaders.lineItemsByOrderId.load(order.id);
},
},
Mutation: {
createOrder: async (_parent, { input }, ctx) => {
if (!ctx.user) throw new AuthenticationError('Must be logged in');
try {
const order = await ctx.db.orders.create({
data: {
customerId: ctx.user.id,
items: input.items,
shippingAddressId: input.shippingAddressId,
},
});
return { order, userErrors: [] };
} catch (error) {
// Return domain errors as userErrors, not thrown errors
if (error instanceof ValidationError) {
return {
order: null,
userErrors: error.fieldErrors.map(fe => ({
message: fe.message,
field: fe.path,
code: fe.code,
})),
};
}
// System errors: throw (will be masked to generic message in production)
throw error;
}
},
},
};
N+1 is the #1 GraphQL performance problem. Every nested field resolver runs once per parent item, causing one DB query per item in a list.
// Without DataLoader: 1 query for list + N queries for each customer = N+1
// With DataLoader: 1 query for list + 1 batched query for all customers = 2
function createLoaders(db: DatabaseClient) {
return {
// Batch load users by ID
user: new DataLoader<string, User | null>(async (userIds) => {
const users = await db.users.findMany({
where: { id: { in: [...userIds] } },
});
const userMap = new Map(users.map(u => [u.id, u]));
// Must return array in same order as input keys
return userIds.map(id => userMap.get(id) ?? null);
}),
// Batch load line items by order ID (one-to-many)
lineItemsByOrderId: new DataLoader<string, LineItem[]>(async (orderIds) => {
const items = await db.lineItems.findMany({
where: { orderId: { in: [...orderIds] } },
});
// Group by order ID
const grouped = groupBy(items, item => item.orderId);
return orderIds.map(id => grouped.get(id) ?? []);
}),
};
}
// Create loaders once per request (NOT per server start — must be per-request)
app.use('/graphql', (req, res, next) => {
req.loaders = createLoaders(db);
next();
});
GraphQL allows deeply nested queries that can cause expensive joins:
# Malicious query — exponential explosion
{ user { orders { customer { orders { customer { orders { ... } } } } } } }
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
validationRules: [
depthLimit(8), // Max query depth
createComplexityLimitRule(1000, { // Max query complexity score
onCost: (cost) => logger.info({ cost }, 'Query complexity'),
}),
],
});
// Only allow pre-approved queries in production
// Client sends hash, server looks up the full query
const server = new ApolloServer({
persistedQueries: {
cache: new KeyValueCache(), // Redis in production
ttl: 900, // 15 minutes
},
});
// In resolvers, check permissions per field
const resolvers = {
User: {
// Only expose email to the user themselves or admins
email: (user, _args, ctx) => {
if (ctx.user?.id === user.id || ctx.user?.isAdmin) {
return user.email;
}
return null; // Or throw ForbiddenError
},
// Salary only visible to HR
salary: (user, _args, ctx) => {
if (!ctx.user?.roles.includes('hr')) {
throw new ForbiddenError('Not authorized');
}
return user.salary;
},
},
};
// Cache individual entity results in Redis across requests
const userLoader = new DataLoader<string, User>(
async (ids) => {
// Check Redis first
const cacheKeys = ids.map(id => `user:${id}`);
const cached = await redis.mget(...cacheKeys);
const missingIds: string[] = [];
const results: Array<User | null> = cached.map((value, i) => {
if (value !== null) return JSON.parse(value);
missingIds.push(ids[i]);
return null;
});
if (missingIds.length > 0) {
const dbUsers = await db.users.findMany({ where: { id: { in: missingIds } } });
const dbMap = new Map(dbUsers.map(u => [u.id, u]));
// Fill in the gaps and cache
const pipeline = redis.pipeline();
for (let i = 0; i < ids.length; i++) {
if (results[i] === null) {
const user = dbMap.get(ids[i]) ?? null;
results[i] = user;
if (user) pipeline.setex(`user:${ids[i]}`, 300, JSON.stringify(user));
}
}
await pipeline.exec();
}
return results;
},
{ cache: false } // DataLoader in-request cache is separate from Redis
);
| Scenario | Prefer | |----------|--------| | Public API, many third-party consumers | REST (simpler to document, familiar) | | Internal API, multiple frontends (web, mobile, desktop) with different data needs | GraphQL | | Simple CRUD, few consumers | REST | | Complex, interrelated domain model | GraphQL | | Real-time subscriptions needed | GraphQL (subscriptions built-in) | | File uploads | REST (GraphQL file uploads are awkward) | | API gateway / proxy | REST or gRPC | | BFF (Backend for Frontend) | GraphQL |
For production GraphQL schema templates and resolver implementation patterns, see:
references/schema-patterns.md — relay-compatible schema patterns, Union/Interface design, input validation, and schema-first development workflowsreferences/resolver-patterns.md — DataLoader implementations, N+1 elimination strategies, subscription resolver patterns, and authorization middleware for resolverstesting
Performs quality control on single-cell RNA-seq data (.h5ad or .h5 files) using scverse best practices with MAD-based filtering and comprehensive visualizations. Use when users request QC analysis, filtering low-quality cells, assessing data quality, or following scverse/scanpy best practices for single-cell analysis.
tools
Deep learning for single-cell analysis using scvi-tools. This skill should be used when users need (1) data integration and batch correction with scVI/scANVI, (2) ATAC-seq analysis with PeakVI, (3) CITE-seq multi-modal analysis with totalVI, (4) multiome RNA+ATAC analysis with MultiVI, (5) spatial transcriptomics deconvolution with DestVI, (6) label transfer and reference mapping with scANVI/scArches, (7) RNA velocity with veloVI, or (8) any deep learning-based single-cell method. Triggers include mentions of scVI, scANVI, totalVI, PeakVI, MultiVI, DestVI, veloVI, sysVI, scArches, variational autoencoder, VAE, batch correction, data integration, multi-modal, CITE-seq, multiome, reference mapping, latent space.
testing
This skill should be used when scientists need help with research problem selection, project ideation, troubleshooting stuck projects, or strategic scientific decisions. Use this skill when users ask to pitch a new research idea, work through a project problem, evaluate project risks, plan research strategy, navigate decision trees, or get help choosing what scientific problem to work on. Typical requests include "I have an idea for a project", "I'm stuck on my research", "help me evaluate this project", "what should I work on", or "I need strategic advice about my research".
development
Run nf-core bioinformatics pipelines (rnaseq, sarek, atacseq) on sequencing data. Use when analyzing RNA-seq, WGS/WES, or ATAC-seq data—either local FASTQs or public datasets from GEO/SRA. Triggers on nf-core, Nextflow, FASTQ analysis, variant calling, gene expression, differential expression, GEO reanalysis, GSE/GSM/SRR accessions, or samplesheet creation.