GraphQL Cursor Rules: API Query Language

Cursor rules for GraphQL covering schema-first design, resolvers, queries/mutations, fragments, error handling with unions, subscriptions, and Apollo Server patterns.

June 8, 2025by PromptGenius Team
graphqltypescriptcursor-rulesapibackend
GraphQL Cursor Rules: API Query Language

Overview

GraphQL gives clients precise control over the data they request, replacing over-fetching REST endpoints with a typed query language. These cursor rules enforce schema-first design, resolver patterns, query/mutation structure, fragment composition, error handling with union types, subscription setup, and Apollo Server conventions to help AI assistants generate clean, type-safe GraphQL APIs.

Note:

Enforces schema-first GraphQL design, typed resolvers with codegen, query/mutation/subscription patterns, fragment-driven data requirements, error handling via union types, DataLoader for N+1 prevention, and Apollo Server v4 conventions.

Rules Configuration

---
description: Enforces GraphQL best practices including schema-first design, resolver patterns, query/mutation/subscription structure, fragment composition, error union types, and Apollo Server configuration. Provides guidelines for type-safe GraphQL APIs.
globs: **/*.graphql,**/*.gql,**/*.ts
---
# GraphQL Best Practices

You are an expert in GraphQL, Apollo Server, and type-safe API design.
You understand schema design, resolver patterns, N+1 prevention, and production deployment.

### Schema Design
- Use schema-first approach: define .graphql files, generate TypeScript types with GraphQL Codegen
- Use singular names for types: type Post, not type Posts
- Use descriptive names for queries: getUser(id: ID!): User, not user(id: ID!)
- Return nullable fields for data that may not exist; use Non-Null (!) for guaranteed fields
- Define inputs for mutations: input CreatePostInput { title: String!, body: String! }
- Use enums for constrained values: enum PostStatus { DRAFT PUBLISHED ARCHIVED }
- Use interfaces or unions for polymorphic responses: union SearchResult = Post | User

### Resolvers
- Keep resolvers thin: delegate to service layer, return typed results
- Use parent, args, context, info signature: (parent, args, context, info) => result
- Type context with authentication info, data loaders, and service instances
- Resolve relations in dedicated field resolvers, not in top-level query resolvers
- Use DataLoader for batching and caching to prevent N+1 queries
- Never expose database entities directly — map to GraphQL types

### Queries & Mutations
- Name queries for what they return: posts, not getPosts
- Name mutations as verbs: createPost, updatePost, deletePost
- Return the mutated object from mutations so clients can update their cache
- Use pagination with Relay-style connections or offset-based page/limit args
- Support filtering and sorting as input arguments
- Mutations should return a payload type: type CreatePostPayload { post: Post! }

### Errors & Unions
- Use union types for operation results: union CreatePostResult = Post | ValidationError
- Define error types with message, code, and field-level details
- Never throw errors from resolvers for expected failures — return them in the union
- Use formatError in Apollo Server to strip stack traces in production
- Log resolver errors centrally with context-aware logging

### Subscriptions
- Use subscriptions for real-time updates: type Subscription { postCreated: Post! }
- Return the full object from subscription resolvers, not just IDs
- Use withFilter for filtering subscription events by criteria
- Authenticate WebSocket connections in the context function

### Apollo Server Setup
- Use Apollo Server v4 with expressMiddleware or startStandaloneServer
- Define context function that attaches auth, loaders, and services per request
- Enable introspection in development, disable in production
- Use persisted queries and automatic persisted queries (APQ) for production
- Set up CSRF prevention for browser-based clients

Installation

Create graphql.mdc in your project's .cursor/rules/ directory and paste the configuration above. Cursor and Windsurf both read .cursor/rules/ — Copilot users place it in .github/copilot-instructions.md instead.

Examples

# schema.graphql — Schema-first definition
type Query {
  posts(page: Int, perPage: Int): PostConnection!
  post(id: ID!): Post
  me: User
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
}

type Subscription {
  postCreated: Post!
}

type Post {
  id: ID!
  title: String!
  body: String!
  published: Boolean!
  author: User!
  comments: [Comment!]!
  createdAt: String!
}

type User {
  id: ID!
  email: String!
  name: String!
  posts: [Post!]!
}

type Comment {
  id: ID!
  body: String!
  author: User!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

input CreatePostInput {
  title: String!
  body: String!
}

input UpdatePostInput {
  title: String
  body: String
  published: Boolean
}

union CreatePostPayload = CreatePostSuccess | ValidationError
union UpdatePostPayload = UpdatePostSuccess | NotFoundError | ValidationError
union DeletePostPayload = DeletePostSuccess | NotFoundError

type CreatePostSuccess { post: Post! }
type UpdatePostSuccess { post: Post! }
type DeletePostSuccess { id: ID! }

type ValidationError {
  message: String!
  code: String!
  fields: [FieldError!]!
}

type FieldError {
  field: String!
  message: String!
}

type NotFoundError {
  message: String!
  code: String!
}
// resolvers/postResolvers.ts
import { PostService } from '../services/postService';
import { Context } from '../context';

export const postResolvers = {
  Query: {
    posts: async (_: unknown, args: { page?: number; perPage?: number }, ctx: Context) => {
      const page = args.page ?? 1;
      const perPage = args.perPage ?? 20;
      const { posts, total } = await ctx.postService.findPublished(page, perPage);
      return {
        posts,
        total,
        edges: posts.map((post) => ({
          node: post,
          cursor: Buffer.from(`post:${post.id}`).toString('base64'),
        })),
        pageInfo: {
          hasNextPage: page * perPage < total,
          hasPreviousPage: page > 1,
          startCursor: posts.length > 0 ? Buffer.from(`post:${posts[0].id}`).toString('base64') : null,
          endCursor: posts.length > 0 ? Buffer.from(`post:${posts[posts.length - 1].id}`).toString('base64') : null,
        },
      };
    },
    post: async (_: unknown, { id }: { id: string }, ctx: Context) => {
      return ctx.loaders.post.load(Number(id));
    },
  },

  Mutation: {
    createPost: async (_: unknown, { input }: { input: CreatePostInput }, ctx: Context) => {
      if (!ctx.user) {
        return { __typename: 'ValidationError', message: 'Unauthorized', code: 'UNAUTHORIZED', fields: [] };
      }
      const errors = validateCreatePost(input);
      if (errors.length > 0) {
        return { __typename: 'ValidationError', message: 'Invalid input', code: 'VALIDATION_ERROR', fields: errors };
      }
      const post = await ctx.postService.create(ctx.user.id, input);
      ctx.pubsub.publish('POST_CREATED', { postCreated: post });
      return { __typename: 'CreatePostSuccess', post };
    },
  },

  Post: {
    author: (parent: { authorId: number }, _: unknown, ctx: Context) => {
      return ctx.loaders.user.load(parent.authorId);
    },
    comments: (parent: { id: number }, _: unknown, ctx: Context) => {
      return ctx.loaders.commentsByPost.load(parent.id);
    },
  },

  CreatePostPayload: {
    __resolveType(obj: { __typename: string }) {
      return obj.__typename;
    },
  },
};
// context.ts — Apollo Server context with DataLoader
import DataLoader from 'dataloader';
import { PubSub } from 'graphql-subscriptions';
import { PostService } from './services/postService';
import { prisma } from './lib/prisma';

export interface Context {
  user: { id: number } | null;
  postService: PostService;
  pubsub: PubSub;
  loaders: {
    post: DataLoader<number, any>;
    user: DataLoader<number, any>;
    commentsByPost: DataLoader<number, any[]>;
  };
}

export async function createContext({ req }: { req: any }): Promise<Context> {
  const user = await getUserFromToken(req.headers.authorization);

  return {
    user,
    postService: new PostService(prisma),
    loaders: {
      post: new DataLoader(async (ids) => {
        const posts = await prisma.post.findMany({ where: { id: { in: [...ids] } } });
        return ids.map((id) => posts.find((p) => p.id === id));
      }),
      user: new DataLoader(async (ids) => {
        const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
        return ids.map((id) => users.find((u) => u.id === id));
      }),
      commentsByPost: new DataLoader(async (postIds) => {
        const comments = await prisma.comment.findMany({ where: { postId: { in: [...postIds] } } });
        return postIds.map((id) => comments.filter((c) => c.postId === id));
      }),
    },
  };
}