home / skills / physics91 / claude-vibe / graphql-reviewer
This skill reviews GraphQL schemas, resolvers, and operations for N+1, query complexity, input validation, and error handling to improve security and
npx playbooks add skill physics91/claude-vibe --skill graphql-reviewerReview the files below or copy the command above to add this skill to your agents.
---
name: graphql-reviewer
description: |
WHEN: GraphQL schema review, resolver patterns, N+1 detection, query complexity, API security
WHAT: Schema design + N+1 detection + Query complexity + Input validation + Error handling + DataLoader patterns
WHEN NOT: REST API → api-documenter, Database schema → schema-reviewer, ORM → orm-reviewer
---
# GraphQL Reviewer Skill
## Purpose
Reviews GraphQL schemas, resolvers, and operations for N+1 problems, query complexity limits, input validation, security best practices, and proper error handling.
## When to Use
- GraphQL schema or resolver review requests
- "GraphQL", "N+1", "DataLoader", "query complexity" mentions
- Schema design review
- Projects with `.graphql`, `.gql` files
- GraphQL library dependencies (Apollo, Relay, graphql-js)
## Project Detection
- `.graphql` or `.gql` schema files
- `schema.graphql` or `type-defs.ts`
- `graphql` package in dependencies
- `@apollo/server`, `graphql-yoga`, `mercurius` dependencies
- `@Query`, `@Mutation`, `@Resolver` decorators (NestJS/TypeGraphQL)
## Workflow
### Step 1: Analyze Project
```
**GraphQL Server**: Apollo Server 4.x / GraphQL Yoga
**Schema**: Code-first / SDL-first
**Language**: TypeScript / JavaScript
**ORM**: Prisma / TypeORM / Drizzle
**Key Features**:
- DataLoader for batching
- Query complexity plugin
- Persisted queries
```
### Step 2: Select Review Areas
**AskUserQuestion:**
```
"Which GraphQL areas to review?"
Options:
- Full GraphQL audit (recommended)
- N+1 / DataLoader patterns
- Schema design
- Query complexity / Security
- Error handling
- Input validation
multiSelect: true
```
## Detection Rules
### Critical: N+1 Query Problem
| Pattern | Issue | Severity |
|---------|-------|----------|
| Resolver per item | N+1 queries | CRITICAL |
| No DataLoader | Unbatched fetches | CRITICAL |
| ORM lazy load in resolver | Hidden N+1 | CRITICAL |
```typescript
// BAD: N+1 problem
// Schema
type Query {
posts: [Post!]!
}
type Post {
id: ID!
author: User! // N+1 here!
}
// Resolver - fetches author per post
const resolvers = {
Query: {
posts: () => db.post.findMany()
},
Post: {
author: (post) => db.user.findUnique({ where: { id: post.authorId } })
// If 100 posts → 1 + 100 queries!
}
};
// GOOD: DataLoader for batching
import DataLoader from 'dataloader';
const createLoaders = () => ({
userLoader: new DataLoader(async (ids: string[]) => {
const users = await db.user.findMany({
where: { id: { in: ids } }
});
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? null);
})
});
// Resolver with DataLoader
const resolvers = {
Post: {
author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId)
// Now: 1 + 1 queries (batched)
}
};
// BEST: Prisma with includes (no N+1)
const resolvers = {
Query: {
posts: () => db.post.findMany({
include: { author: true } // Single query with JOIN
})
}
};
```
### Critical: Excessive Fetching in Resolvers
| Pattern | Issue | Severity |
|---------|-------|----------|
| SELECT * in resolver | Over-fetching | HIGH |
| No field selection | Wasted resources | MEDIUM |
| Ignoring selection set | Missing optimization | HIGH |
```typescript
// BAD: Fetches all fields regardless of query
const resolvers = {
Query: {
user: (_, { id }) => db.user.findUnique({
where: { id },
include: {
posts: true, // Maybe not requested
comments: true, // Maybe not requested
followers: true // Maybe not requested
}
})
}
};
// GOOD: Use info to select only requested fields
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
const resolvers = {
Query: {
user: (_, { id }, __, info: GraphQLResolveInfo) => {
const requestedFields = graphqlFields(info);
return db.user.findUnique({
where: { id },
include: {
posts: 'posts' in requestedFields,
comments: 'comments' in requestedFields
}
});
}
}
};
// BETTER: Use Prisma's select based on GraphQL query
import { PrismaSelect } from '@paljs/plugins';
const resolvers = {
Query: {
user: (_, { id }, __, info) => {
const select = new PrismaSelect(info).value;
return db.user.findUnique({ where: { id }, ...select });
}
}
};
```
### Critical: Mutation in Query
| Pattern | Issue | Severity |
|---------|-------|----------|
| Side effects in Query | Violates spec | CRITICAL |
| Write operation in Query | Unexpected behavior | CRITICAL |
```graphql
# BAD: Mutation disguised as Query
type Query {
incrementViewCount(postId: ID!): Int! # WRONG! This mutates data
markAsRead(notificationId: ID!): Boolean! # WRONG!
}
# GOOD: Mutations for side effects
type Mutation {
incrementViewCount(postId: ID!): Post!
markAsRead(notificationId: ID!): Notification!
}
# Query should be idempotent (read-only)
type Query {
post(id: ID!): Post
viewCount(postId: ID!): Int!
}
```
### High: Missing Input Validation
| Pattern | Issue | Severity |
|---------|-------|----------|
| No validation in resolver | Bad data accepted | HIGH |
| Trusting client input | Security risk | HIGH |
| No sanitization | Injection risk | CRITICAL |
```typescript
// BAD: No validation
const resolvers = {
Mutation: {
createUser: (_, { input }) => {
// input.email could be anything
return db.user.create({ data: input });
}
}
};
// GOOD: Validate inputs
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
const resolvers = {
Mutation: {
createUser: (_, { input }) => {
const validated = CreateUserSchema.parse(input);
return db.user.create({ data: validated });
}
}
};
// Schema-level validation (GraphQL)
"""
User creation input
"""
input CreateUserInput {
email: String! @constraint(format: "email")
name: String! @constraint(minLength: 1, maxLength: 100)
age: Int @constraint(min: 0, max: 150)
}
```
### High: No Query Complexity Limit
| Pattern | Issue | Severity |
|---------|-------|----------|
| Unlimited depth | DoS vector | HIGH |
| No complexity limit | Resource exhaustion | HIGH |
| No rate limiting | Abuse possible | MEDIUM |
```typescript
// BAD: Allows dangerous queries
// Can request: user.friends.friends.friends.friends...
// GOOD: Limit query depth
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)] // Max 5 levels deep
});
// GOOD: Query complexity plugin
import { createComplexityPlugin } from 'graphql-query-complexity';
const complexityPlugin = createComplexityPlugin({
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
],
maximumComplexity: 1000,
onComplete: (complexity) => {
console.log('Query Complexity:', complexity);
}
});
// Schema with complexity hints
type Query {
users(first: Int!): [User!]! @complexity(multipliers: ["first"], value: 5)
posts(first: Int!): [Post!]! @complexity(multipliers: ["first"], value: 3)
}
// GOOD: Rate limiting
import { rateLimitDirective } from 'graphql-rate-limit-directive';
const { rateLimitDirectiveTypeDefs, rateLimitDirectiveTransformer } =
rateLimitDirective();
type Query {
expensiveQuery: Data! @rateLimit(limit: 10, duration: 60)
}
```
### High: No List Pagination
| Pattern | Issue | Severity |
|---------|-------|----------|
| Unbounded lists | Memory exhaustion | HIGH |
| No cursor pagination | Poor performance | MEDIUM |
| Missing total count | Bad UX | LOW |
```graphql
# BAD: Unbounded list
type Query {
posts: [Post!]! # Could return millions!
}
# GOOD: Relay-style pagination
type Query {
posts(
first: Int
after: String
last: Int
before: String
): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# SIMPLER: Offset pagination (for small datasets)
type Query {
posts(offset: Int = 0, limit: Int = 20): PostPage!
}
type PostPage {
items: [Post!]!
totalCount: Int!
hasMore: Boolean!
}
```
### High: Missing Error Handling
| Pattern | Issue | Severity |
|---------|-------|----------|
| Throwing raw errors | Leaks info | HIGH |
| No error codes | Hard to handle | MEDIUM |
| Stack traces in response | Security risk | HIGH |
```typescript
// BAD: Raw error exposure
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
throw new Error('User not found'); // Generic error
}
return user;
}
}
};
// GOOD: Structured GraphQL errors
import { GraphQLError } from 'graphql';
class NotFoundError extends GraphQLError {
constructor(resource: string, id: string) {
super(`${resource} not found`, {
extensions: {
code: 'NOT_FOUND',
resource,
id
}
});
}
}
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundError('User', id);
}
return user;
}
}
};
// Error formatting plugin
const formatError = (error: GraphQLError) => {
// Don't expose internal errors
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' }
});
}
return error;
};
```
### Medium: Internal ID Exposure
| Pattern | Issue | Severity |
|---------|-------|----------|
| Database ID in schema | Information leak | MEDIUM |
| Sequential IDs | Enumeration risk | MEDIUM |
| No ID obfuscation | Privacy concern | LOW |
```graphql
# BAD: Exposes database IDs
type User {
id: Int! # Sequential, guessable
}
# GOOD: Use opaque IDs
type User {
id: ID! # Could be UUID, hashid, etc.
}
```
```typescript
// ID encoding/decoding
import Hashids from 'hashids';
const hashids = new Hashids('secret-salt', 10);
const resolvers = {
User: {
id: (user) => hashids.encode(user.dbId)
},
Query: {
user: (_, { id }) => {
const [dbId] = hashids.decode(id);
return db.user.findUnique({ where: { id: dbId } });
}
}
};
```
### Medium: Missing Non-null Defaults
| Pattern | Issue | Severity |
|---------|-------|----------|
| Nullable without reason | Confusing API | MEDIUM |
| Everything nullable | Too permissive | LOW |
```graphql
# BAD: Unnecessarily nullable
type User {
id: ID # Should always exist
email: String # Required for user
name: String # Should be required
bio: String # OK to be nullable
}
# GOOD: Clear nullability
type User {
id: ID! # Always present
email: String! # Required
name: String! # Required
bio: String # Optional (nullable)
deletedAt: DateTime # Optional
}
# For fields that may fail to resolve
type Post {
id: ID!
author: User # Nullable if author deleted
authorId: ID! # Always has the reference
}
```
## Response Template
```
## GraphQL Code Review Results
**Project**: [name]
**Server**: Apollo Server 4.x
**Schema**: SDL-first / Code-first
### N+1 / DataLoader
#### CRITICAL
| File | Line | Issue |
|------|------|-------|
| resolvers/post.ts | 23 | N+1 in author resolver - use DataLoader |
| resolvers/user.ts | 45 | posts fetched per user without batching |
### Query Complexity / Security
| File | Line | Issue |
|------|------|-------|
| server.ts | 12 | No depth limit configured |
| schema.graphql | 34 | posts query unbounded - add pagination |
### Input Validation
| File | Line | Issue |
|------|------|-------|
| mutations/user.ts | 56 | No email validation |
| mutations/post.ts | 23 | Missing input sanitization |
### Error Handling
| File | Line | Issue |
|------|------|-------|
| resolvers/query.ts | 78 | Raw error thrown - use GraphQLError |
### Schema Design
| File | Line | Issue |
|------|------|-------|
| schema.graphql | 12 | Query with side effect - move to Mutation |
| types/user.graphql | 8 | Exposes sequential database ID |
### Recommendations
1. [ ] Implement DataLoader for all relationship resolvers
2. [ ] Add depth limit (max 5-7 levels)
3. [ ] Add query complexity plugin (max 1000)
4. [ ] Add pagination to all list fields
5. [ ] Validate all mutation inputs with Zod/Yup
### Positive Patterns
- Good use of Relay connections for pagination
- Proper error codes in GraphQL errors
```
## Best Practices
1. **DataLoader**: Always batch relationship resolvers
2. **Complexity**: Limit depth and complexity
3. **Pagination**: Cursor-based for large lists
4. **Validation**: Validate all inputs server-side
5. **Errors**: Use structured GraphQL errors
6. **Security**: Rate limit, no introspection in prod
## Integration
- `schema-reviewer` skill: Database schema
- `orm-reviewer` skill: ORM patterns
- `typescript-reviewer` skill: TS type safety
- `security-scanner` skill: API security
## Notes
- Based on GraphQL best practices 2024
- Works with Apollo, Yoga, Mercurius
- Supports both SDL and code-first
- Compatible with Prisma, TypeORM, Drizzle
This skill reviews GraphQL schemas, resolvers, and operations to find performance, correctness, and security problems. It focuses on N+1 detection, query complexity limits, input validation, error handling, and recommended DataLoader patterns. The goal is concrete, prioritized recommendations you can apply to Apollo, Yoga, or Mercurius servers.
The reviewer scans schema files, resolver code, and dependency manifests to detect patterns like per-item resolvers, missing DataLoader usage, unbounded lists, and absent complexity rules. It analyzes resolver implementation for excessive fetching, selection-set ignorance, mutation-in-query anti-patterns, and missing input validation. The output is a concise audit with severity-classified findings and actionable fixes (e.g., DataLoader, Prisma selects, depth/complexity plugins).
Can this run on code-first and SDL-first schemas?
Yes. The reviewer supports both code-first and SDL-first patterns and detects resolver-level and schema-level issues.
Does it recommend specific libraries or configurations?
It recommends practical fixes such as DataLoader for batching, Prisma selects or ORM includes, graphql-depth-limit, graphql-query-complexity plugins, Zod/Yup for validation, and structured GraphQLError usage.