GraphQL Design Skill
GraphQL Design Skill
How to Use This Skill
- Review the patterns and examples below
- Apply the relevant patterns to your implementation
- Follow the best practices outlined in this skill
Production-ready GraphQL API design skill implementing industry best practices for schema design, efficient resolvers, DataLoader integration for N+1 prevention, and security patterns.
When to Use This Skill
Use graphql-design when:
- Designing new GraphQL APIs or schemas
- Implementing resolvers and mutations
- Setting up real-time subscriptions
- Optimizing query performance (N+1 problem)
- Planning schema federation architecture
- Implementing authorization in GraphQL
Don't use graphql-design when:
- Building REST APIs (use
restful-api-designskill) - Only need API versioning (use
api-versioningskill) - Working on gRPC or WebSocket protocols
- Security-only review (use
security-auditskill)
GraphQL Design Principles
Schema Design
| Principle | Description | Example |
|---|---|---|
| Demand-Driven | Clients request exactly what they need | { user { name email } } |
| Strongly Typed | Schema defines all types explicitly | type User { id: ID! } |
| Hierarchical | Data follows object relationships | user.posts.comments |
| Introspective | Schema is queryable | __schema { types { name } } |
| Versioned | Schema evolves without breaking | Deprecation over removal |
Type System
# Scalar types
scalar DateTime
scalar UUID
scalar JSON
# Object types
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
createdAt: DateTime!
}
# Input types (for mutations)
input CreateUserInput {
email: String!
name: String!
password: String!
}
# Enums
enum UserRole {
ADMIN
USER
GUEST
}
# Interfaces
interface Node {
id: ID!
}
# Unions
union SearchResult = User | Post | Comment
Nullability
# Non-null field (always returns value)
name: String!
# Nullable field (can return null)
bio: String
# Non-null list of non-null items
posts: [Post!]!
# Non-null list of nullable items
friends: [User]!
# Nullable list of nullable items (rare)
tags: [String]
Instructions
Phase 1: Schema Design
Objective: Design type-safe, well-structured schema.
-
Define domain types:
# types/user.graphql
type User implements Node {
id: ID!
email: String!
name: String!
role: UserRole!
posts(first: Int, after: String): PostConnection!
profile: Profile
createdAt: DateTime!
updatedAt: DateTime!
}
type Profile {
bio: String
avatar: String
website: String
location: String
}
enum UserRole {
ADMIN
MODERATOR
USER
} -
Design connections (Relay pattern):
# Relay-style pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
} -
Define queries:
type Query {
# Single resource by ID
user(id: ID!): User
post(id: ID!): Post
# Collections with pagination
users(
first: Int
after: String
filter: UserFilter
orderBy: UserOrderBy
): UserConnection!
# Search
search(query: String!, types: [SearchType!]): SearchConnection!
# Current user (requires auth)
me: User
}
input UserFilter {
role: UserRole
createdAfter: DateTime
createdBefore: DateTime
}
input UserOrderBy {
field: UserOrderField!
direction: OrderDirection!
}
enum UserOrderField {
CREATED_AT
NAME
EMAIL
}
enum OrderDirection {
ASC
DESC
} -
Define mutations:
type Mutation {
# User mutations
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
# Post mutations
createPost(input: CreatePostInput!): CreatePostPayload!
publishPost(id: ID!): PublishPostPayload!
# Auth mutations
login(email: String!, password: String!): AuthPayload!
logout: LogoutPayload!
}
# Input types
input CreateUserInput {
email: String!
name: String!
password: String!
role: UserRole = USER
}
input UpdateUserInput {
name: String
bio: String
avatar: String
}
# Payload types (include errors)
type CreateUserPayload {
user: User
errors: [Error!]!
}
type Error {
field: String
message: String!
code: ErrorCode!
}
enum ErrorCode {
VALIDATION_ERROR
NOT_FOUND
UNAUTHORIZED
FORBIDDEN
}
Phase 2: Resolver Implementation
Objective: Implement efficient, secure resolvers.
-
Basic resolver pattern:
// resolvers/user.ts
import { Resolvers } from '../generated/graphql';
export const userResolvers: Resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUserById(id);
},
users: async (_, { first, after, filter, orderBy }, { dataSources }) => {
return dataSources.userAPI.getUsers({ first, after, filter, orderBy });
},
me: async (_, __, { currentUser }) => {
if (!currentUser) return null;
return currentUser;
},
},
Mutation: {
createUser: async (_, { input }, { dataSources }) => {
try {
const user = await dataSources.userAPI.createUser(input);
return { user, errors: [] };
} catch (error) {
return {
user: null,
errors: [{ message: error.message, code: 'VALIDATION_ERROR' }],
};
}
},
},
User: {
posts: async (parent, { first, after }, { dataSources }) => {
return dataSources.postAPI.getPostsByUser(parent.id, { first, after });
},
},
}; -
DataLoader for N+1 prevention:
// dataloaders/userLoader.ts
import DataLoader from 'dataloader';
import { User } from '../models';
export const createUserLoader = () => {
return new DataLoader<string, User>(async (ids) => {
// Batch fetch users
const users = await User.findAll({
where: { id: ids },
});
// Map results to maintain order
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) || null);
});
};
// Usage in resolver
const resolvers = {
Post: {
author: async (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
},
},
}; -
Context setup:
// context.ts
import { createUserLoader } from './dataloaders/userLoader';
import { createPostLoader } from './dataloaders/postLoader';
export interface Context {
currentUser: User | null;
loaders: {
userLoader: ReturnType<typeof createUserLoader>;
postLoader: ReturnType<typeof createPostLoader>;
};
}
export const createContext = async ({ req }): Promise<Context> => {
const token = req.headers.authorization?.replace('Bearer ', '');
const currentUser = token ? await verifyToken(token) : null;
return {
currentUser,
loaders: {
userLoader: createUserLoader(),
postLoader: createPostLoader(),
},
};
};
Phase 3: Subscriptions
Objective: Implement real-time GraphQL subscriptions.
-
Define subscription types:
type Subscription {
postCreated: Post!
postUpdated(id: ID!): Post!
userOnlineStatus(userId: ID!): OnlineStatusPayload!
commentAdded(postId: ID!): Comment!
}
type OnlineStatusPayload {
user: User!
isOnline: Boolean!
} -
Implement with PubSub:
// subscriptions/post.ts
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
export const POST_CREATED = 'POST_CREATED';
export const POST_UPDATED = 'POST_UPDATED';
export const subscriptionResolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator([POST_CREATED]),
},
postUpdated: {
subscribe: (_, { id }) => {
return pubsub.asyncIterator([`${POST_UPDATED}.${id}`]);
},
},
},
};
// Publish from mutation
export const mutationResolvers = {
Mutation: {
createPost: async (_, { input }, { currentUser }) => {
const post = await Post.create({ ...input, authorId: currentUser.id });
// Publish event
pubsub.publish(POST_CREATED, { postCreated: post });
return { post, errors: [] };
},
},
};
Phase 4: Security & Authorization
Objective: Implement secure GraphQL API.
-
Field-level authorization:
// directives/auth.ts
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { GraphQLSchema } from 'graphql';
export function authDirectiveTransformer(schema: GraphQLSchema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0];
if (authDirective) {
const { requires } = authDirective;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
const { currentUser } = context;
if (!currentUser) {
throw new Error('Not authenticated');
}
if (requires && !requires.includes(currentUser.role)) {
throw new Error('Not authorized');
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
} -
Schema directive:
directive @auth(requires: [UserRole!]) on FIELD_DEFINITION
type Query {
users: [User!]! @auth(requires: [ADMIN])
me: User @auth(requires: [USER, ADMIN])
}
type User {
id: ID!
email: String! @auth(requires: [ADMIN])
name: String!
} -
Query complexity limiting:
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityLimitRule = createComplexityLimitRule(1000, {
onCost: (cost) => {
console.log(`Query cost: ${cost}`);
},
formatErrorMessage: (cost) =>
`Query complexity ${cost} exceeds maximum allowed complexity of 1000`,
});
const server = new ApolloServer({
schema,
validationRules: [complexityLimitRule],
}); -
Depth limiting:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(10)],
});
Examples
Example 1: Apollo Server Setup
// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';
const typeDefs = /* GraphQL */ `
type Query {
users: [User!]!
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
`;
const resolvers = {
Query: {
users: (_, __, { dataSources }) => dataSources.userAPI.getUsers(),
user: (_, { id }, { dataSources }) => dataSources.userAPI.getUserById(id),
},
User: {
posts: (user, _, { loaders }) => loaders.postLoader.load(user.id),
},
Post: {
author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const server = new ApolloServer({ schema });
const app = express();
await server.start();
app.use('/graphql', expressMiddleware(server, { context: createContext }));
Example 2: Strawberry (Python) Setup
# schema.py
import strawberry
from strawberry.types import Info
from typing import List, Optional
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str
@strawberry.field
async def posts(self, info: Info) -> List["Post"]:
loader = info.context["loaders"]["post_loader"]
return await loader.load(self.id)
@strawberry.type
class Post:
id: strawberry.ID
title: str
content: str
author_id: strawberry.Private[str]
@strawberry.field
async def author(self, info: Info) -> User:
loader = info.context["loaders"]["user_loader"]
return await loader.load(self.author_id)
@strawberry.type
class Query:
@strawberry.field
async def users(self, info: Info) -> List[User]:
return await info.context["dataSources"]["user_api"].get_users()
@strawberry.field
async def user(self, id: strawberry.ID, info: Info) -> Optional[User]:
return await info.context["dataSources"]["user_api"].get_user_by_id(id)
schema = strawberry.Schema(query=Query)
Integration
Related Components
- Skill:
restful-api-design- Alternative API approach - Skill:
api-versioning- Schema evolution strategies - Agent:
senior-architect- API architecture decisions - Skill:
security-audit- GraphQL security review
Tooling
# Generate types from schema
npx graphql-codegen
# Lint GraphQL operations
npx graphql-eslint
# Test queries
npx graphql-playground
# Schema validation
npx graphql-inspector validate schema.graphql
Troubleshooting
| Issue | Solution |
|---|---|
| N+1 queries | Use DataLoader for batching |
| Slow queries | Add query complexity limits |
| Memory issues | Implement cursor pagination |
| Schema conflicts | Use schema stitching/federation |
| Auth bypass | Add directive-based authorization |
Success Output
When this skill completes successfully, output:
✅ SKILL COMPLETE: graphql-design
Completed:
- [x] GraphQL schema designed with proper type system
- [x] Queries and mutations defined with input/payload types
- [x] Resolvers implemented with DataLoader for N+1 prevention
- [x] Subscriptions configured with PubSub
- [x] Field-level authorization directives added
- [x] Query complexity and depth limits enforced
- [x] Schema documentation with descriptions
Outputs:
- Schema files: types/*.graphql (User, Post, Connection types)
- Resolvers: resolvers/*.ts with DataLoader integration
- Authorization: @auth directive with role-based access
- Pagination: Relay-style cursor connections
- Security: Complexity limit (1000), depth limit (10)
- Subscriptions: Real-time updates via WebSocket
Completion Checklist
Before marking this skill as complete, verify:
- Schema uses proper nullability (
!for required fields) - Input types defined for all mutations
- Payload types include errors field for mutations
- DataLoader implemented for all object relationships
- Relay-style pagination for collections (edges, pageInfo)
- Field-level authorization with
@authdirective - Query complexity limit configured (default: 1000)
- Query depth limit configured (default: 10)
- Subscriptions use PubSub pattern
- Schema includes descriptions for all types and fields
Failure Indicators
This skill has FAILED if:
- ❌ N+1 query problem exists (no DataLoader for relationships)
- ❌ Mutations return objects directly instead of payload types
- ❌ No error handling in mutation payloads
- ❌ Offset pagination used instead of cursor pagination
- ❌ No authorization directives (open access to all fields)
- ❌ No query complexity or depth limits (DoS vulnerability)
- ❌ Subscriptions use polling instead of WebSocket
- ❌ Schema lacks descriptions (poor developer experience)
- ❌ Input types missing or use query types
When NOT to Use
Do NOT use this skill when:
- Building simple REST APIs (use
restful-api-designskill instead) - Need request/response caching at HTTP level (GraphQL has different caching)
- File uploads are primary use case (GraphQL file upload has limitations)
- Working with legacy systems expecting REST (use GraphQL gateway instead)
- Team has no GraphQL experience (training overhead vs REST)
- Need HTTP status codes for client logic (GraphQL always returns 200)
- Building public APIs requiring HTTP caching (CDN, browser cache)
Use alternatives:
- restful-api-design - For traditional REST APIs with HTTP semantics
- grpc-design - For high-performance RPC with protocol buffers
- websocket-design - For real-time bidirectional communication only
- rest-graphql-gateway - For gradual GraphQL adoption over REST
Anti-Patterns (Avoid)
| Anti-Pattern | Problem | Solution |
|---|---|---|
| No DataLoader | N+1 queries, slow performance | Use DataLoader for all relationships |
| Offset pagination | Inconsistent results, poor performance | Use Relay cursor pagination |
| Returning objects from mutations | No error handling | Return payload types with errors field |
| No complexity limits | DoS attacks via deep queries | Add complexity and depth limits |
| No field-level auth | Security vulnerabilities | Use @auth directive on sensitive fields |
| Generic error messages | Poor debugging experience | Return structured errors with codes |
| No input types | Mutations hard to extend | Always use input types for mutations |
| Nullable required fields | Runtime errors | Use ! for required fields |
| No schema descriptions | Poor developer experience | Document all types and fields |
| Ignoring subscription cleanup | Memory leaks | Properly unsubscribe and clean up |
Principles
This skill embodies these CODITECT principles:
- #1 Demand-Driven - Clients request exactly what they need, no over-fetching
- #2 Strongly Typed - Schema defines all types explicitly, compile-time safety
- #3 Performance - DataLoader batching eliminates N+1 queries
- #4 Security - Field-level authorization, complexity limits, depth limits
- #5 Eliminate Ambiguity - Explicit nullability, input/payload types
- #6 Clear, Understandable - Schema introspection, inline documentation
- #7 Resilient - Error handling in payload types, not exceptions
- #8 No Assumptions - Verify query complexity and depth before execution
Reference: CODITECT-STANDARD-AUTOMATION.md
Best Practices Checklist
- Use non-null types appropriately (
!) - Implement DataLoader for all relationships
- Add query depth and complexity limits
- Use input types for mutations
- Return payload types with errors from mutations
- Implement Relay-style pagination
- Add field-level authorization
- Document schema with descriptions
- Use schema stitching for microservices
- Monitor query performance
References
Status: Production-ready GraphQL Spec: October 2021 Supported Frameworks: Apollo, GraphQL Yoga, Strawberry, async-graphql Integration: DataLoader, schema federation, security directives