| name | graphql-api-design |
| description | 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". |
GraphQL API Design
Production-quality patterns for GraphQL schema design, resolver implementation, and performance.
Schema Design Principles
Start From the Consumer, Not the Data Model
GraphQL is about the data consumers need, not the database schema. The two should often diverge.
type orders_table {
order_id: Int!
customer_fk: Int!
order_status_enum: String!
created_timestamp: Int!
}
type Order {
id: ID!
status: OrderStatus!
customer: Customer!
createdAt: DateTime!
lineItems: [LineItem!]!
totalAmount: Money!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Naming Conventions
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
Non-Null Discipline
type User {
id: ID!
email: String!
firstName: String!
avatar: String
deletedAt: DateTime
}
Complete Schema Template
scalar DateTime
scalar UUID
scalar JSON
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
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
}
type Query {
order(id: ID!): Order
orders(
first: Int
after: String
last: Int
before: String
filter: OrderFilter
orderBy: OrderOrderBy
): OrderConnection!
currentUser: User
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddressId: ID!
paymentMethodId: ID!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
type CreateOrderPayload {
order: Order
userErrors: [UserError!]!
}
type UserError {
message: String!
field: [String!]!
code: String!
}
input OrderFilter {
status: OrderStatus
createdAfter: DateTime
createdBefore: DateTime
customerId: ID
}
enum OrderOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
TOTAL_CENTS_ASC
TOTAL_CENTS_DESC
}
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatusChangedPayload!
}
type OrderStatusChangedPayload {
order: Order!
previousStatus: OrderStatus!
}
Resolver Implementation
Basic Resolver Structure
import DataLoader from 'dataloader';
const resolvers = {
Query: {
order: async (_parent, { id }, ctx) => {
if (!ctx.user) throw new AuthenticationError('Must be logged in');
const order = await ctx.db.orders.findById(id);
if (!order) return null;
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;
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,
}),
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,
},
};
},
},
Order: {
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) {
if (error instanceof ValidationError) {
return {
order: null,
userErrors: error.fieldErrors.map(fe => ({
message: fe.message,
field: fe.path,
code: fe.code,
})),
};
}
throw error;
}
},
},
};
DataLoader — Fixing N+1 Queries
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.
function createLoaders(db: DatabaseClient) {
return {
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]));
return userIds.map(id => userMap.get(id) ?? null);
}),
lineItemsByOrderId: new DataLoader<string, LineItem[]>(async (orderIds) => {
const items = await db.lineItems.findMany({
where: { orderId: { in: [...orderIds] } },
});
const grouped = groupBy(items, item => item.orderId);
return orderIds.map(id => grouped.get(id) ?? []);
}),
};
}
app.use('/graphql', (req, res, next) => {
req.loaders = createLoaders(db);
next();
});
Security
Depth and Complexity Limits
GraphQL allows deeply nested queries that can cause expensive joins:
{ 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),
createComplexityLimitRule(1000, {
onCost: (cost) => logger.info({ cost }, 'Query complexity'),
}),
],
});
Persisted Queries (Defense Against Arbitrary Queries in Production)
const server = new ApolloServer({
persistedQueries: {
cache: new KeyValueCache(),
ttl: 900,
},
});
Field-Level Authorization
const resolvers = {
User: {
email: (user, _args, ctx) => {
if (ctx.user?.id === user.id || ctx.user?.isAdmin) {
return user.email;
}
return null;
},
salary: (user, _args, ctx) => {
if (!ctx.user?.roles.includes('hr')) {
throw new ForbiddenError('Not authorized');
}
return user.salary;
},
},
};
Performance Patterns
Request Caching with DataLoader + Redis
const userLoader = new DataLoader<string, User>(
async (ids) => {
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]));
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 }
);
GraphQL vs REST Decision Guide
| 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 |
Deeper Reference
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 workflows
references/resolver-patterns.md — DataLoader implementations, N+1 elimination strategies, subscription resolver patterns, and authorization middleware for resolvers