All Articles Claude Code

Claude Code for GraphQL Schema Review

GraphQL schemas are where frontend and backend meet. Get them right, and you've got a clean contract. Get them wrong, and you've got a documentation problem that's actually a design problem.

GraphQL schemas are where frontend and backend meet. Get them right, and you’ve got a clean contract. Get them wrong, and you’ve got a documentation problem that’s actually a design problem. Someone releases a mutation that nobody needed. Someone breaks a query that 47 consumers depend on. Someone adds a field that’s needlessly expensive to resolve. And because GraphQL’s type system is permissive, these problems slip through until they’re in production.

The good news: Claude Code is exceptionally good at reasoning about graph structures, type relationships, and performance implications. We’re going to build a GraphQL schema review system that does what most teams never do—actually validates that your schema is well-designed, backwards-compatible, and performant before it ships.

This isn’t a schema linter in the traditional sense. We’re talking deep analysis: breaking change detection, resolver performance estimation, naming convention enforcement, query complexity analysis, and automatic suggestions for schema improvements. It’s the kind of review that would take a senior engineer 30 minutes of careful reading. We’re automating it.

Why GraphQL Schemas Matter (And Why Reviews Are Hard)

Here’s the honest problem with GraphQL: the schema is your API contract, but nobody really “reviews” it. You write it, tests pass, it deploys, and only later do you realize:

  • You added a field that requires three database queries to populate (N+1 waiting to happen)
  • You changed a return type from nullable to non-nullable, breaking 15 mobile apps
  • You used inconsistent naming conventions (some fields camelCase, some snake_case)
  • Your resolver is doing a JOIN across three tables for a field that nobody actually uses
  • You’ve got circular references in your schema that create impossible query situations

The schema lives in your code repository, but it’s usually treated like documentation—important but easy to skip. And because GraphQL gives you so much flexibility, the bar for “it compiles and works” is really low.

What we want instead is automated review that:

  1. Detects breaking changes — before merging to main
  2. Analyzes query complexity — to catch performance traps
  3. Validates naming conventions — consistency without bikeshedding
  4. Reviews resolver implementations — finds N+1 and expensive queries
  5. Suggests improvements — turns schema debt into actionable items

Claude Code excels at this because GraphQL schemas are parseable, well-structured, and the implications are mostly semantic—exactly what LLMs are good at.

The Real-World Impact of Schema Decisions

Let’s talk about what happens when schema reviews don’t happen. I’ve seen organizations where GraphQL schemas evolved organically without much forethought. Features got added, fields accumulated, naming conventions shifted. Five years later, the schema was a mess. Not a broken mess—it worked. But it was inefficient, confusing, and expensive to maintain.

One organization had a User type with about forty fields. Some were simple strings. Others were relationships—posts, followers, comments—that each required separate database lookups. The worst was a computed field that aggregated all user activity across three tables. Nobody questioned it in code review. It looked fine in the schema. It didn’t break anything.

But then a mobile app tried to load a user profile. That simple query was executing fifteen database queries. The app was slow, users were frustrated, and the team had no idea why. When they finally traced it, they realized the schema design encouraged expensive queries. Fixing it required careful migration and deprecation planning because changing field nullability or removing fields breaks existing clients.

Another team built their schema without naming conventions. Some fields were camelCase (JavaScript convention), some snake_case (database convention), some just abbreviated for brevity. It wasn’t broken code, but it was confusing. Developers made mistakes. Frontend code had weird mapping layers. API documentation had to explain conventions that didn’t exist. It was technical debt that accumulated because nobody reviewed the schema holistically.

And then there’s the breaking change problem. A team modified a field from nullable to non-nullable. Seemed reasonable—the field should always have a value. But one mobile app didn’t know how to handle non-nullable fields in that position. The app crashed in production. Rollback. Apology. Now the team is more conservative about schema changes, which is good, but they have no systematic way to catch these issues before they happen.

These are all preventable with actual schema review. The challenge is that traditional code review doesn’t catch these issues. A human reviewer sees “Field ‘User.posts’: [Post!]!” and thinks, “okay, a user can have posts.” They don’t automatically think about N+1 queries, DataLoader batching, or resolver performance. That requires domain knowledge that you can’t expect every reviewer to have.

Setting Up Your GraphQL Schema Analyzer

We’ll start with a TypeScript/Node setup, but the patterns apply to any GraphQL server.

Before diving into implementation, understand that schema analysis happens at multiple levels. At the lowest level, you’re checking if the schema is valid according to GraphQL spec. At the next level, you’re checking whether it follows conventions and naming patterns your organization has chosen. At the highest level, you’re reasoning about performance characteristics and API design quality.

Claude Code’s strength is at these higher levels. Any GraphQL parser can tell you if the schema is syntactically valid. But understanding that a particular schema design will cause N+1 queries, or that a field is unnecessarily expensive, or that a breaking change will impact dozens of client applications—that requires the kind of reasoning Claude Code can do.

The analyzer we’re building will report on all three levels, but will spend most of its computational effort on those highest-level concerns.

First, the schema file itself. Here’s a typical GraphQL schema with some intentional issues:

# schema.graphql
# User and Social Graph Management Schema

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  feed(limit: Int): [Post!]!
  search(query: String!): SearchResult
}

type User {
  id: ID!
  email: String!
  username: String!
  first_name: String # Inconsistent: snake_case among camelCase
  last_name: String # Inconsistent naming
  fullName: String! # Derived, requires computation
  profile: UserProfile! # New type, adds resolver depth
  posts: [Post!]! # Can N+1 if naively implemented
  followers: [User!]! # Another N+1 candidate
  followerCount: Int! # Better than followers for count use case
  created_at: String! # Should be DateTime scalar
  updated_at: String! # Should be DateTime scalar
}

type UserProfile {
  bio: String
  avatarUrl: String
  location: String
  socialLinks: [SocialLink!]!
}

type SocialLink {
  platform: String!
  url: String!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User! # Requires user lookup per post
  comments: [Comment!]! # N+1 if not batched
  commentCount: Int!
  likeCount: Int!
  createdAt: DateTime!
  viewCount: Int # Not typically cached; expensive if computed
}

type Comment {
  id: ID!
  text: String!
  author: User! # Another N+1
  post: Post! # Circular reference: Post -> Comment -> Post
  createdAt: DateTime!
}

type SearchResult {
  users: [User!]!
  posts: [Post!]!
}

# Mutations should document what they do
type Mutation {
  createUser(email: String!, password: String!): User!
  updateUser(id: ID!, name: String): User
  deleteUser(id: ID!): Boolean!
  createPost(title: String!, content: String!): Post!
  updatePost(id: ID!, title: String, content: String): Post
  deletePost(id: ID!): Boolean!
  addComment(postId: ID!, text: String!): Comment!
  likePost(postId: ID!): Post!
}

type Subscription {
  postCreated: Post!
  userFollowed: User!
}

There are real issues here:

  • Naming inconsistency: first_name and last_name use snake_case, everything else is camelCase
  • N+1 performance: posts, followers, comments all require batching
  • Type inconsistency: created_at is String instead of DateTime scalar
  • Circular references: Post → Comment → Post creates query complexity issues
  • Expensive computed fields: viewCount on Post might be expensive to compute
  • Unclear mutability semantics: updateUser has optional fields but no clear semantics on partial updates

Let’s build the system to catch all of this.

Building the Schema Analyzer Core

Create a script that loads the schema and runs comprehensive analysis:

// schema-analyzer.ts




interface AnalysisResult {
  schema: DocumentNode;
  timestamp: string;
  violations: Violation[];
  suggestions: Suggestion[];
  breakingChanges: BreakingChange[];
  performanceRisks: PerformanceRisk[];
  overallScore: number;
}

interface Violation {
  type:
    | "NAMING_INCONSISTENCY"
    | "NULLABLE_MISMATCH"
    | "CIRCULAR_REFERENCE"
    | "TYPE_CONSISTENCY";
  severity: "ERROR" | "WARNING" | "INFO";
  location: string;
  message: string;
  suggestion: string;
}

interface Suggestion {
  category: "NAMING" | "STRUCTURE" | "PERFORMANCE" | "DOCUMENTATION" | "DESIGN";
  priority: "HIGH" | "MEDIUM" | "LOW";
  message: string;
}

interface BreakingChange {
  field: string;
  type: string;
  change: string;
  impact: string;
  mitigations: string[];
}

interface PerformanceRisk {
  field: string;
  type: string;
  risk: string;
  estimatedQueryImpact: string;
  mitigations: string[];
}

export async function analyzeSchema(
  schemaPath: string,
): Promise<AnalysisResult> {
  const schemaContent = fs.readFileSync(schemaPath, "utf-8");
  const schema = buildSchema(schemaContent);

  const violations: Violation[] = [];
  const suggestions: Suggestion[] = [];
  const breakingChanges: BreakingChange[] = [];
  const performanceRisks: PerformanceRisk[] = [];

  // Analysis 1: Naming Conventions
  violations.push(...analyzeNamingConventions(schemaContent));

  // Analysis 2: Type Consistency
  violations.push(...analyzeTypeConsistency(schema));

  // Analysis 3: Nullable Field Patterns
  violations.push(...analyzeNullablePatterns(schema));

  // Analysis 4: Performance Red Flags
  performanceRisks.push(...analyzePerformanceRisks(schema));

  // Analysis 5: Circular References
  violations.push(...detectCircularReferences(schema));

  // Analysis 6: Schema Maturity
  suggestions.push(...generateSuggestions(schema));

  // Compute overall score
  const errorCount = violations.filter((v) => v.severity === "ERROR").length;
  const warningCount = violations.filter(
    (v) => v.severity === "WARNING",
  ).length;
  const overallScore = Math.max(0, 5 - errorCount * 0.5 - warningCount * 0.25);

  return {
    schema,
    timestamp: new Date().toISOString(),
    violations,
    suggestions,
    breakingChanges,
    performanceRisks,
    overallScore,
  };
}

function analyzeNamingConventions(schemaContent: string): Violation[] {
  const violations: Violation[] = [];

  // Check for mixed case conventions
  const fieldRegex = /\s+(\w+):\s*\[?[\w!]+\]?/g;
  const fields: Set<string> = new Set();
  let match;

  while ((match = fieldRegex.exec(schemaContent)) !== null) {
    fields.add(match[1]);
  }

  let camelCaseCount = 0;
  let snake_case_count = 0;

  fields.forEach((field) => {
    if (/^[a-z][a-zA-Z0-9]*$/.test(field) && !/_/.test(field)) {
      camelCaseCount++;
    } else if (/_/.test(field)) {
      snake_case_count++;
    }
  });

  if (camelCaseCount > 0 && snake_case_count > 0) {
    violations.push({
      type: "NAMING_INCONSISTENCY",
      severity: "WARNING",
      location: "Global",
      message: `Schema uses mixed naming conventions: ${camelCaseCount} camelCase fields, ${snake_case_count} snake_case fields`,
      suggestion:
        "Choose one convention (recommend camelCase for GraphQL) and rename all fields consistently",
    });
  }

  // Check for common misnaming patterns
  const misspelledDateFields = schemaContent.match(
    /(created_at|updated_at|created_date|updated_date).*String/g,
  );
  if (misspelledDateFields) {
    violations.push({
      type: "TYPE_CONSISTENCY",
      severity: "WARNING",
      location: misspelledDateFields[0] || "Unknown",
      message: `Date field is String type instead of DateTime scalar`,
      suggestion: `Use DateTime scalar instead of String for ${misspelledDateFields[0]}`,
    });
  }

  return violations;
}

function analyzeTypeConsistency(schema: any): Violation[] {
  const violations: Violation[] = [];

  // This would integrate with Claude for deeper analysis
  // For now, basic checks

  return violations;
}

function analyzeNullablePatterns(schema: any): Violation[] {
  const violations: Violation[] = [];

  // Check for inconsistent nullability on list fields
  // e.g., [User!]! vs [User]! patterns

  return violations;
}

function analyzePerformanceRisks(schema: any): PerformanceRisk[] {
  const risks: PerformanceRisk[] = [];

  // This is where Claude shines: identifying fields that are likely N+1
  // Fields like: User.posts, Post.comments, User.followers

  return risks;
}

function detectCircularReferences(schema: any): Violation[] {
  const violations: Violation[] = [];

  // Detect Post -> Comment -> Post cycles
  // These aren't always bad, but they complicate query planning

  return violations;
}

function generateSuggestions(schema: any): Suggestion[] {
  const suggestions: Suggestion[] = [];

  // Generate actionable improvement suggestions
  // e.g., "Add @deprecated directives to fields before removing"
  // e.g., "Consider batching loaders for User.posts"

  return suggestions;
}

// Run analysis and output
const result = await analyzeSchema("./schema.graphql");
console.log(JSON.stringify(result, null, 2));

Real-World Schema Review Examples

Before we dive into implementation, let’s look at concrete examples of issues that automated schema review catches.

Example 1: The Silent N+1 Query

A developer adds a followers field to the User type that returns all users following the current user. The schema looks clean and reasonable. But if the resolver doesn’t batch the queries (using DataLoader or similar), each user query becomes N+1. When a feed shows ten users, and the frontend requests followers for each, you suddenly execute eleven database queries instead of one.

Schema review that understands resolver implications would catch this: “Field ‘User.followers’ requires individual database lookups per user. If fetched for multiple users in a single query, will cause N+1 problem. Mitigation: Implement DataLoader batching or add pagination with limits.”

The developer sees this, realizes the performance implication, and implements proper batching before the PR merges. Or they decide the field isn’t worth the complexity and removes it. Either way, the decision is informed by understanding the implications.

Example 2: The Breaking Change Nobody Noticed

A developer changes a field from user(id: ID!): User to user(id: ID!): User! (making it non-nullable). In GraphQL, this is a breaking change if any existing clients expect the field to be nullable. Mobile apps might have code that handles null users gracefully and crashes when they get an error instead.

Automated review that tracks previous schema versions catches this: “Field ‘Query.user’ changed from nullable to non-nullable. This is a breaking change. Clients expecting nullable will fail with an error. Affected queries: GetUserProfile, GetUserSettings (if we’ve analyzed client queries). Mitigation: Add new field ‘user_required’ with non-nullable type, deprecate the old field, wait for client migration.”

The developer sees this, realizes the breaking change, and follows a deprecation path instead of breaking clients directly.

Example 3: The Inconsistent Convention

A schema starts with camelCase field names: firstName, lastName. Then a new feature adds snake_case fields: phone_number, address_line_1. This isn’t a breaking change and doesn’t cause performance problems. But it’s inconsistent and confusing for API users.

Schema review catches this: “Field ‘User.phone_number’ uses snake_case while other User fields use camelCase. Recommendation: Use camelCase consistently. Rename to ‘phoneNumber’.”

This seems like a small thing, but consistency across an API massively improves developer experience. When fields follow a consistent pattern, developers can predict what fields are called. When they’re inconsistent, developers constantly need to look them up. At scale across dozens of types and hundreds of fields, consistency saves hours of frustration.

Deep Dive: Breaking Change Detection

The most critical analysis is breaking change detection. Before we merge a schema change, we need to know what it breaks:

// breaking-change-detector.ts


interface BreakingChangeReport {
  breaking: string[];
  dangerous: string[];
  safeChanges: string[];
  affectedQueries: string[];
}

export async function detectBreakingChanges(
  oldSchemaPath: string,
  newSchemaPath: string,
  queriesPath?: string,
): Promise<BreakingChangeReport> {
  const oldSchema = fs.readFileSync(oldSchemaPath, "utf-8");
  const newSchema = fs.readFileSync(newSchemaPath, "utf-8");

  // Use GraphQL Inspector to find breaking changes
  const differences = diffSchema(
    buildSchema(oldSchema),
    buildSchema(newSchema),
  );

  const breaking: string[] = [];
  const dangerous: string[] = [];
  const safeChanges: string[] = [];

  differences.forEach((change) => {
    if (change.criticality === "breaking") {
      breaking.push(change.description);
    } else if (change.criticality === "dangerous") {
      dangerous.push(change.description);
    } else {
      safeChanges.push(change.description);
    }
  });

  // If queries provided, analyze impact on existing queries
  let affectedQueries: string[] = [];
  if (queriesPath && fs.existsSync(queriesPath)) {
    affectedQueries = await analyzeQueryImpact(
      queriesPath,
      breaking,
      dangerous,
    );
  }

  return {
    breaking,
    dangerous,
    safeChanges,
    affectedQueries,
  };
}

async function analyzeQueryImpact(
  queriesPath: string,
  breaking: string[],
  dangerous: string[],
): Promise<string[]> {
  // Parse all .graphql query files
  // Cross-reference with breaking/dangerous changes
  // Return list of queries that would break

  const files = fs
    .readdirSync(queriesPath)
    .filter((f) => f.endsWith(".graphql"));
  const affected: string[] = [];

  files.forEach((file) => {
    const query = fs.readFileSync(path.join(queriesPath, file), "utf-8");

    // Simple pattern matching for affected fields
    breaking.forEach((change) => {
      if (query.includes(change.split(" ")[0])) {
        affected.push(`${file}: likely affected by "${change}"`);
      }
    });
  });

  return affected;
}

Usage example:

# Compare schema versions
ts-node breaking-change-detector.ts \
  --old ./schema-old.graphql \
  --new ./schema-new.graphql \
  --queries ./src/queries

Output:

{
  "breaking": [
    "Field 'User.email' is no longer nullable but had default null",
    "Type 'SearchResult' was removed",
    "Argument 'users.limit' type changed from Int to String"
  ],
  "dangerous": [
    "Field 'Post.viewCount' was added as non-nullable without default"
  ],
  "safeChanges": [
    "Field 'User.avatarUrl' was added as nullable",
    "Argument 'feed.sortBy' type was changed from String to enum"
  ],
  "affectedQueries": [
    "GetUserEmail.graphql: uses User.email field that changed nullability",
    "SearchUsers.graphql: uses removed SearchResult type",
    "FeedQuery.graphql: uses removed 'limit' argument"
  ]
}

Understanding Performance in GraphQL

Performance is where schema design gets truly interesting. A query that looks simple in the schema might be exponentially expensive in execution. The classic N+1 problem emerges when you don’t batch requests properly.

Consider a query that fetches a user’s posts, and for each post, fetches the author, and for each author fetches their followers. If you’re not careful with batching, that’s one query to get the user, one query to get the posts (already N+1 if you’re fetching authors per post), one query per post to get the author, and one query per author to get followers. What should be a handful of queries becomes hundreds.

GraphQL makes it easy to express these queries because the schema allows nesting. The challenge is ensuring that resolvers handle the nesting efficiently. Without visibility into resolver implementations, you might not know you have a performance problem until it’s in production and users are complaining.

Schema review helps by identifying fields that are likely to cause performance problems. Then you can design the resolver implementation to handle them efficiently (using DataLoader for batching), or you can redesign the schema to avoid the problem altogether.

Query Complexity Analysis

One of the most overlooked aspects of GraphQL is query complexity. A seemingly innocent query can multiply into hundreds of database hits if resolvers aren’t properly batched:

// query-complexity-analyzer.ts


interface ComplexityScore {
  query: string;
  score: number;
  estimatedDbQueries: number;
  riskLevel: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
  issues: ComplexityIssue[];
}

interface ComplexityIssue {
  field: string;
  path: string;
  risk: string;
  estimatedQueries: number;
  recommendation: string;
}

const FIELD_COSTS: Record<string, number> = {
  "User.posts": 5, // Accessing User.posts requires a DB query (N+1 risk)
  "Post.author": 3, // Requires user lookup per post
  "Post.comments": 8, // N+1 if not batched
  "Comment.author": 3,
  "User.followers": 10, // Expensive relationship query
  "User.profile": 2, // Single lookup
};

export function analyzeQueryComplexity(query: string): ComplexityScore {
  const document = parse(query);
  const issues: ComplexityIssue[] = [];
  let totalScore = 0;
  let estimatedDbQueries = 1; // Base query

  // Walk the AST and identify expensive fields
  visit(document, {
    Field(node: FieldNode, key, parent, path, ancestors) {
      const fieldName = node.name.value;
      const parentType = ancestors[ancestors.length - 2] as FieldNode;
      const parentName = parentType?.name?.value || "Query";

      const fieldKey = `${parentName}.${fieldName}`;
      const cost = FIELD_COSTS[fieldKey] || 1;

      totalScore += cost;

      // Check for N+1 patterns
      if (
        FIELD_COSTS[fieldKey] &&
        FIELD_COSTS[fieldKey] > 2 &&
        cost > totalScore / 10
      ) {
        issues.push({
          field: fieldName,
          path: fieldKey,
          risk: `Accessing ${fieldName} on multiple ${parentName} could cause N+1 queries`,
          estimatedQueries: cost,
          recommendation: `Ensure resolver uses DataLoader batching for ${fieldKey}`,
        });

        estimatedDbQueries += cost;
      }
    },
  });

  // Determine risk level
  let riskLevel: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" = "LOW";
  if (totalScore > 100) riskLevel = "CRITICAL";
  else if (totalScore > 50) riskLevel = "HIGH";
  else if (totalScore > 20) riskLevel = "MEDIUM";

  return {
    query,
    score: totalScore,
    estimatedDbQueries,
    riskLevel,
    issues,
  };
}

// Example usage
const query = `
  query GetUserFeed {
    user(id: "123") {
      id
      username
      followers { # HIGH COST: 10 points
        id
        username
        posts { # N+1: 5 points per user
          id
          title
          author { # N+1: 3 points per post
            id
            username
          }
          comments { # N+1: 8 points per post
            id
            text
            author { # N+1: 3 points per comment
              id
              username
            }
          }
        }
      }
    }
  }
`;

const complexity = analyzeQueryComplexity(query);
console.log(complexity);

The Cost of Schema Mistakes at Scale

Let me paint a picture of what happens when schema review doesn’t exist. An organization builds a GraphQL API over two years. Engineers add fields, types, and resolvers as needed. Syntax is valid, tests pass. But gradually, issues accumulate.

A field that seemed simple when added is now used by three different client applications. Changing it would break them all. New schema designs are constrained by what already exists. Performance problems discovered in production require complex resolver refactoring. Naming inconsistencies make the API hard to learn and hard to document. Breaking changes get made accidentally and discovered when apps crash in production.

By the end of two years, the organization has invested thousands of engineering hours in managing schema complexity that good review practices would have prevented. They’re constrained in how they can evolve the API. New features take longer to implement because they have to work within the existing schema’s limitations.

Then a new team leader says, “Let’s implement schema review,” and suddenly the pain points become visible. Every PR is flagged with issues that should have been caught years ago. The team’s velocity seems to drop because they’re finally addressing technical debt. But they’re actually just making explicit what was always wrong.

The organizations that implement schema review from the start avoid this accumulation of debt. The API evolves more thoughtfully. Decisions are made with visibility into implications. The technical debt never builds up in the first place.

Resolver Implementation Validation

Now let’s check that resolvers actually implement what the schema promises. This requires analyzing resolver code:

// resolver-validator.ts




interface ResolverAnalysis {
  field: string;
  implementation: string;
  issues: ResolverIssue[];
  performance: PerformanceCharacteristic[];
}

interface ResolverIssue {
  type: "N_PLUS_ONE" | "MISSING_DATALOADER" | "INEFFICIENT_QUERY" | "MISSING";
  severity: "ERROR" | "WARNING";
  description: string;
  suggestion: string;
}

interface PerformanceCharacteristic {
  characteristic:
    | "DATABASE_QUERY"
    | "EXTERNAL_API_CALL"
    | "COMPUTATION"
    | "BATCHED";
  count: number;
  isPotentiallyExpensive: boolean;
}

export function analyzeResolvers(
  resolversPath: string,
  schemaPath: string,
): ResolverAnalysis[] {
  const schemaContent = fs.readFileSync(schemaPath, "utf-8");
  const results: ResolverAnalysis[] = [];

  // Get all resolver files
  const files = fs
    .readdirSync(resolversPath)
    .filter((f) => f.endsWith(".ts") || f.endsWith(".js"));

  files.forEach((file) => {
    const code = fs.readFileSync(path.join(resolversPath, file), "utf-8");
    const ast = parse(code, {
      sourceType: "module",
      plugins: ["typescript"],
    });

    traverse(ast, {
      FunctionDeclaration(path) {
        const functionName = path.node.id?.name;
        if (!functionName) return;

        const issues = detectResolverIssues(path.node, functionName);
        const performance = analyzePerformanceCharacteristics(
          path.node,
          functionName,
        );

        results.push({
          field: functionName,
          implementation: code.substring(
            path.node.start || 0,
            path.node.end || 1000,
          ),
          issues,
          performance,
        });
      },
    });
  });

  return results;
}

function detectResolverIssues(
  node: any,
  functionName: string,
): ResolverIssue[] {
  const issues: ResolverIssue[] = [];

  // Check for N+1 patterns: loops with DB queries
  const code = node.toString();

  // Pattern 1: for loop with DB query
  if (code.match(/for\s*\(.*\)\s*{[\s\S]*?db\.(query|find)/)) {
    issues.push({
      type: "N_PLUS_ONE",
      severity: "ERROR",
      description: `${functionName} has a loop with database queries—likely N+1 pattern`,
      suggestion: "Use DataLoader or batch query instead of looping",
    });
  }

  // Pattern 2: Missing DataLoader usage for relationship fields
  if (
    (functionName.includes("posts") ||
      functionName.includes("comments") ||
      functionName.includes("followers")) &&
    !code.includes("DataLoader")
  ) {
    issues.push({
      type: "MISSING_DATALOADER",
      severity: "WARNING",
      description: `${functionName} should use DataLoader for batching`,
      suggestion: `Implement DataLoader for ${functionName} to prevent N+1`,
    });
  }

  // Pattern 3: Inefficient query patterns
  if (code.match(/SELECT \*|\.find\(\{\}\)|\.query\(\)/)) {
    issues.push({
      type: "INEFFICIENT_QUERY",
      severity: "WARNING",
      description: `${functionName} uses broad query patterns`,
      suggestion: "Specify exact columns/fields needed",
    });
  }

  return issues;
}

function analyzePerformanceCharacteristics(
  node: any,
  functionName: string,
): PerformanceCharacteristic[] {
  const code = node.toString();
  const characteristics: PerformanceCharacteristic[] = [];

  if (code.includes("db.query") || code.includes("prisma")) {
    characteristics.push({
      characteristic: "DATABASE_QUERY",
      count: (code.match(/db\.query|prisma/g) || []).length,
      isPotentiallyExpensive:
        (code.match(/db\.query|prisma/g) || []).length > 1,
    });
  }

  if (code.includes("fetch") || code.includes("http")) {
    characteristics.push({
      characteristic: "EXTERNAL_API_CALL",
      count: (code.match(/fetch|http/g) || []).length,
      isPotentiallyExpensive: true,
    });
  }

  if (code.includes("DataLoader")) {
    characteristics.push({
      characteristic: "BATCHED",
      count: 1,
      isPotentiallyExpensive: false,
    });
  }

  return characteristics;
}

Comprehensive Schema Review Integration

Now we pull it all together. Claude Code will orchestrate all analyses and provide human-readable recommendations:

// comprehensive-schema-review.ts




interface ComprehensiveReview {
  summary: string;
  grade: string;
  analyses: {
    namingConsistency: any;
    breakingChanges: any;
    queryComplexity: any;
    resolverQuality: any;
  };
  recommendations: string[];
  nextSteps: string[];
}

export async function runComprehensiveSchemaReview(
  newSchemaPath: string,
  oldSchemaPath?: string,
  resolversPath?: string,
  queriesPath?: string,
): Promise<ComprehensiveReview> {
  // Collect all analyses
  const namingAnalysis = analyzeSchema(newSchemaPath);
  const breakingChanges = oldSchemaPath
    ? await detectBreakingChanges(oldSchemaPath, newSchemaPath, queriesPath)
    : null;
  const resolverAnalysis = resolversPath
    ? analyzeResolvers(resolversPath, newSchemaPath)
    : null;

  // Prepare comprehensive context for Claude
  const context = {
    schema: fs.readFileSync(newSchemaPath, "utf-8"),
    namingAnalysis,
    breakingChanges,
    resolverAnalysis,
    timestamp: new Date().toISOString(),
  };

  // Invoke Claude with full context
  return new Promise((resolve, reject) => {
    const claude = spawn("claude-code", [
      "analyze-graphql",
      "--context",
      JSON.stringify(context),
      "--output-format",
      "json",
    ]);

    let output = "";
    claude.stdout.on("data", (data) => {
      output += data.toString();
    });

    claude.on("close", (code) => {
      if (code === 0) {
        try {
          resolve(JSON.parse(output) as ComprehensiveReview);
        } catch (e) {
          reject(new Error(`Failed to parse Claude output: ${output}`));
        }
      } else {
        reject(new Error(`Claude analysis failed with code ${code}`));
      }
    });
  });
}

// Usage example
async function main() {
  const review = await runComprehensiveSchemaReview(
    "./schema.graphql",
    "./schema-old.graphql",
    "./src/resolvers",
    "./src/queries",
  );

  console.log("=== GraphQL Schema Review ===");
  console.log(`Grade: ${review.grade}`);
  console.log(`Summary: ${review.summary}`);
  console.log("\nRecommendations:");
  review.recommendations.forEach((rec, i) => {
    console.log(`${i + 1}. ${rec}`);
  });

  // Save full report
  writeFileSync("schema-review-report.json", JSON.stringify(review, null, 2));
}

main().catch(console.error);

Running as Part of Your Review Process

Let’s integrate this into your CI/CD pipeline. Create a GitHub Action that automatically reviews schema changes:

# .github/workflows/graphql-schema-review.yml

name: GraphQL Schema Review

on:
  pull_request:
    paths:
      - "schema.graphql"
      - "src/resolvers/**"
      - "src/queries/**"

jobs:
  review-schema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Set up Node
        uses: actions/setup-node@v3
        with:
          node-version: "18"

      - name: Install dependencies
        run: npm ci

      - name: Get base schema
        run: git show origin/main:schema.graphql > schema.base.graphql

      - name: Run comprehensive schema review
        run: |
          npm run schema:review -- \
            --new schema.graphql \
            --old schema.base.graphql \
            --resolvers src/resolvers \
            --queries src/queries \
            --output review-report.json

      - name: Generate comment
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const report = JSON.parse(fs.readFileSync('review-report.json', 'utf8'));

            let comment = `## GraphQL Schema Review\n\n`;
            comment += `**Grade: ${report.grade}**\n\n`;

            if (report.recommendations.length > 0) {
              comment += `### Recommendations\n`;
              report.recommendations.forEach(rec => {
                comment += `- ${rec}\n`;
              });
            }

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment
            });

      - name: Fail if critical issues
        if: contains(fromJSON(steps.run_review.outputs.grade), 'F')
        run: |
          echo "Schema review failed with critical issues"
          exit 1

Real-World Impact and Cultural Shift

When you wire this into your development workflow, something interesting happens: developers start thinking differently about schema design. They know that:

  • Naming must be consistent (or they’ll get flagged)
  • Breaking changes must be justified (or they’ll get questioned)
  • N+1 queries will be caught (so why bother trying to sneak them in)
  • Performance implications are visible before merging

The review system becomes a teacher, not a gatekeeper. Junior engineers learn what good schema design looks like by seeing the feedback on their PRs. Senior engineers can focus on architectural questions instead of mechanical ones.

I’ve seen this transformation firsthand. Teams that implement automated schema review report that developers spend less time in review discussions about naming conventions and more time designing better APIs. The automated checker handles the mechanical questions, freeing humans to think about design intent.

There’s also a psychological element. When a developer knows their schema will be analyzed for performance implications, they think about it differently when writing. They ask themselves: “Will this field need DataLoader batching?” or “Is this circular reference going to cause problems?” These questions lead to better design.

The tools also create institutional memory. Documentation of performance patterns, security considerations, and naming conventions lives in the code that runs the review. New team members can understand why decisions were made by reading the check descriptions. This is much more effective than maintaining separate documentation that inevitably falls out of sync.

Handling Schema Evolution and Versioning

As your API matures, you’ll face schema evolution challenges. You need to change the API to add features or fix problems, but you have existing clients depending on the current schema. The naive approach is careful versioning or maintaining multiple schema versions in parallel. Both are expensive and error-prone.

Better schema review systems help you navigate this complexity. When you’re thinking about modifying a field, the review process can suggest deprecation paths. Instead of breaking the field, you might mark it deprecated and add a new field with the corrected behavior. Over several releases, clients migrate to the new field. Eventually, you remove the old field. This is standard practice in mature APIs, but only organizations with good schema review actually execute it consistently.

Claude Code can help here by suggesting deprecation strategies based on the change being made. Change in nullability? Consider a parallel field with the correct nullability while keeping the old one deprecated. Change in semantics? Suggest a rename with the old name deprecated. The system can even help estimate how many clients might be affected by a breaking change, based on query analysis.

Performance Monitoring and Optimization

As your API grows and usage patterns emerge, you’ll discover that some fields are way more expensive to resolve than others. The query complexity analyzer helps identify these, but the real value emerges when you connect this analysis to production usage data.

Advanced implementations correlate schema review findings with actual GraphQL query metrics from production. A field that the analyzer flagged as high-complexity is actually unused (nobody queries it), so why optimize it? Meanwhile, a seemingly innocuous field is being queried millions of times daily and needs aggressive caching. The intersection of theoretical complexity analysis and real-world usage patterns tells you exactly what to optimize.

This requires integrating with your GraphQL observability tools—systems like Apollo Studio, GraphQL Mesh, or custom query logging. The schema review then becomes part of an optimization feedback loop. You spot expensive fields. You check if they’re actually used. You decide whether to optimize, deprecate, or redesign them. The review system helps you understand the implications of each choice.

Advanced: Custom Rule Development and Organization-Specific Standards

You can extend the schema reviewer with domain-specific rules tailored to your organization’s API design philosophy. Different teams have different requirements. A fintech company might enforce strict input validation requirements. A social media platform might care deeply about performance and privacy implications. A B2B API provider might need strong versioning discipline.

The flexibility to define custom rules means your schema review system adapts to your specific context rather than forcing you to adapt to a generic tool. This is particularly valuable as your organization matures and develops its own API design patterns and practices.

The ability to define custom rules means the review system grows with your organization. Early on, you enforce basic conventions. As you mature, you add rules that encode institutional knowledge and lessons learned from past incidents.

For example, a team that was burned by circular references blocking certain query patterns might add a custom rule that detects problematic circular references. Another team that had performance problems from underestimated resolver costs might add a rule that requires documentation for any resolver that could potentially touch more than one database table.

These custom rules become documentation of your API design philosophy. They’re not written in separate design documents that get outdated. They’re embedded in the review system that runs on every PR. This ensures the philosophy is actually practiced, not just aspirational.

Custom rules can also be versioned and evolved. You might start with a strict policy and gradually relax it as you understand the exceptions. The history of rule changes tells the story of how your API standards evolved and why.

Integration with API Development Lifecycle

Effective schema review isn’t a one-off gate. It’s part of a broader API development lifecycle that includes planning, design, review, testing, and monitoring. Claude Code fits into this lifecycle by automating the parts of design review that can be systematized.

Before a PR is opened, developers might sketch schema changes in discussions. The review system can’t help there, but it ensures that when code is ready, it’s checked comprehensively. After merge, the schema is deployed. Additional monitoring can track how the schema performs in production and flag schemas that are creating unexpected query patterns.

Advanced organizations create feedback loops where production metrics inform schema design decisions. A field that looked well-designed in review but generates unexpected query patterns in production is flagged for redesign in the next planning cycle. This closes the loop between design intent and operational reality.

Developer Experience and Productivity

One surprising benefit of good schema review is that it actually speeds up development. Developers don’t have to wonder if their schema design is correct. They get clear, actionable feedback in minutes rather than waiting days for a senior engineer’s review. When there are issues, the feedback is specific. Not “this doesn’t look right” but “this field requires N database lookups per request, consider using DataLoader.”

This also reduces the friction in design discussions. Instead of debating naming conventions in code review comments, the automated check enforces conventions, and discussions focus on exceptions or style changes. The tone shifts from “you got it wrong” to “here’s what the system flagged; let’s address it together.”

New team members ramp up faster because they learn API design patterns through feedback. They see why certain decisions matter, not just that they do. The review system becomes a teaching tool that’s always available and consistent.

Scaling Schema Review Across Teams

As your organization grows, you’ll have multiple teams building different services, all exposing GraphQL APIs. How do you ensure consistency across the entire organization? How do you prevent one team from making mistakes that another team learned from years ago?

Shared rule sets and style guides stored in version control solve this. All teams use the same schema review rules, either directly or with org-specific customizations. This ensures that API quality remains consistent even as the number of services grows.

You can also use schema review as a way to enforce federation patterns if you’re building a federated GraphQL architecture. Rules can check that types follow federation conventions, that cross-service references are properly set up, and that federation directives are used correctly.

Organizational schema registries—centralized places where all schemas are registered and searchable—become much more useful when they’re populated with review metadata. Instead of just storing schemas, the registry also stores review results, performance characteristics, and breaking change history. Teams considering reusing types from other services can see whether those types have known performance issues or deprecation plans.

Why This Matters

Most teams never do this level of schema review. The result:

  • Queries get slower over time (N+1 problems accumulate)
  • APIs become inconsistently named (makes integrations harder)
  • Breaking changes slip through (until mobile apps crash)
  • Resolvers become performance nightmares (and nobody knows why)
  • Schema debt accumulates silently until it requires expensive refactoring

With Claude Code handling the analysis, you catch these issues at review time. You know exactly what you’re shipping, why it’s safe (or not), and what the performance implications are. The beauty of automated schema review is that it scales with your API growth. Early on when you have a small schema with few consumers, the issues are manageable. But as your API grows to hundreds of types and thousands of fields, with hundreds of consumers and millions of daily queries, the only way to maintain quality is systematic analysis. The system can’t forget to check for performance implications just because it’s tired. It can’t give you the “looks fine” rubber-stamp when there’s actually a subtle bug.

The schema is your contract with the world. It deserves better than a passing glance. It deserves review that understands not just syntax, but semantics and implications. When you’re designing an API that other teams and external developers will build upon, the stakes are real. Every design decision compounds. A small mistake in field naming becomes muscle memory for dozens of consumers. A performance problem that nobody caught metastasizes. A breaking change that slips through affects production systems.

Claude Code gives you a way to say “we take our API design seriously.” Not in a gatekeeping way, but in a way that helps everyone build better APIs. The feedback is faster. The insights are deeper. The confidence in your releases is higher. That’s worth the investment.


-iNet

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.