Skip to main content

GraphQL Design Skill

GraphQL Design Skill

How to Use This Skill

  1. Review the patterns and examples below
  2. Apply the relevant patterns to your implementation
  3. 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-design skill)
  • Only need API versioning (use api-versioning skill)
  • Working on gRPC or WebSocket protocols
  • Security-only review (use security-audit skill)

GraphQL Design Principles

Schema Design

PrincipleDescriptionExample
Demand-DrivenClients request exactly what they need{ user { name email } }
Strongly TypedSchema defines all types explicitlytype User { id: ID! }
HierarchicalData follows object relationshipsuser.posts.comments
IntrospectiveSchema is queryable__schema { types { name } }
VersionedSchema evolves without breakingDeprecation 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.

  1. 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
    }
  2. 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
    }
  3. 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
    }
  4. 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.

  1. 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 });
    },
    },
    };
  2. 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);
    },
    },
    };
  3. 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.

  1. 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!
    }
  2. 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.

  1. 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;
    },
    });
    }
  2. 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!
    }
  3. 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],
    });
  4. 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

  • 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

IssueSolution
N+1 queriesUse DataLoader for batching
Slow queriesAdd query complexity limits
Memory issuesImplement cursor pagination
Schema conflictsUse schema stitching/federation
Auth bypassAdd 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 @auth directive
  • 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-design skill 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-PatternProblemSolution
No DataLoaderN+1 queries, slow performanceUse DataLoader for all relationships
Offset paginationInconsistent results, poor performanceUse Relay cursor pagination
Returning objects from mutationsNo error handlingReturn payload types with errors field
No complexity limitsDoS attacks via deep queriesAdd complexity and depth limits
No field-level authSecurity vulnerabilitiesUse @auth directive on sensitive fields
Generic error messagesPoor debugging experienceReturn structured errors with codes
No input typesMutations hard to extendAlways use input types for mutations
Nullable required fieldsRuntime errorsUse ! for required fields
No schema descriptionsPoor developer experienceDocument all types and fields
Ignoring subscription cleanupMemory leaksProperly 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