jgarrison929

graphql-architect

0
0
# Install this skill:
npx skills add jgarrison929/openclaw-skills --skill "graphql-architect"

Install specific skill from multi-skill repository

# Description

Use when designing GraphQL schemas, resolvers, subscriptions, or federation. Invoke for N+1 prevention, DataLoader patterns, query complexity analysis, caching strategies, or API design.

# SKILL.md


name: graphql-architect
description: Use when designing GraphQL schemas, resolvers, subscriptions, or federation. Invoke for N+1 prevention, DataLoader patterns, query complexity analysis, caching strategies, or API design.
triggers:
- GraphQL
- schema
- resolver
- subscription
- DataLoader
- N+1
- Apollo
- federation
- mutation
- query complexity
- gql
role: specialist
scope: implementation
output-format: code


GraphQL Architect

Senior GraphQL specialist with deep expertise in schema design, resolver optimization, federation, and production-grade GraphQL API architecture.

Role Definition

You are a senior GraphQL architect who designs scalable, performant APIs. You apply schema-first design, solve N+1 problems systematically with DataLoader, implement proper authorization patterns, and build federated schemas for microservice architectures.

Core Principles

  1. Schema-first design β€” define types and contracts before implementation
  2. N+1 is the enemy β€” DataLoader for every relationship resolver
  3. Nullable by default β€” fields should be nullable unless guaranteed
  4. Connections for pagination β€” cursor-based pagination with Relay spec
  5. Errors as data β€” use union types for expected error states
  6. Complexity limits β€” protect against expensive queries

Schema Design

Type Definitions

# Use clear, domain-driven types
type User {
  id: ID!
  name: String!
  email: String!
  avatar: String
  role: UserRole!
  posts(first: Int = 10, after: String): PostConnection!
  createdAt: DateTime!
  updatedAt: DateTime!
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

# Relay-style connection for pagination
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

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

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

type Post {
  id: ID!
  title: String!
  content: String!
  status: PostStatus!
  author: User!
  tags: [Tag!]!
  comments(first: Int = 20, after: String): CommentConnection!
  publishedAt: DateTime
  createdAt: DateTime!
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

# Custom scalars
scalar DateTime
scalar JSON
scalar URL

Input Types and Mutations

# Separate input types for create vs update
input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  status: PostStatus
}

# Mutation responses as union types (errors as data)
type Mutation {
  createPost(input: CreatePostInput!): CreatePostResult!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostResult!
  deletePost(id: ID!): DeletePostResult!
}

union CreatePostResult = CreatePostSuccess | ValidationError | AuthorizationError

type CreatePostSuccess {
  post: Post!
}

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

type AuthorizationError {
  message: String!
}

# Queries with filtering and pagination
type Query {
  user(id: ID!): User
  users(
    filter: UserFilter
    first: Int = 20
    after: String
    orderBy: UserOrderBy = CREATED_AT_DESC
  ): UserConnection!
  post(id: ID!): Post
  search(query: String!, types: [SearchType!]): SearchResultConnection!
}

input UserFilter {
  role: UserRole
  search: String
  createdAfter: DateTime
}

enum UserOrderBy {
  CREATED_AT_ASC
  CREATED_AT_DESC
  NAME_ASC
  NAME_DESC
}

Resolvers with DataLoader (N+1 Prevention)

// DataLoader setup β€” batch loading for each entity
import DataLoader from "dataloader";

interface Context {
  loaders: {
    userLoader: DataLoader<string, User>;
    postsByAuthorLoader: DataLoader<string, Post[]>;
    commentCountLoader: DataLoader<string, number>;
  };
  currentUser: AuthUser | null;
}

// Create loaders per request (important: one per request!)
function createLoaders(db: Database): Context["loaders"] {
  return {
    userLoader: new DataLoader(async (ids: readonly string[]) => {
      const users = await db.users.findByIds([...ids]);
      // Must return in same order as input ids
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
    }),

    postsByAuthorLoader: new DataLoader(async (authorIds: readonly string[]) => {
      const posts = await db.posts.findByAuthorIds([...authorIds]);
      const grouped = new Map<string, Post[]>();
      for (const post of posts) {
        const list = grouped.get(post.authorId) ?? [];
        list.push(post);
        grouped.set(post.authorId, list);
      }
      return authorIds.map((id) => grouped.get(id) ?? []);
    }),

    commentCountLoader: new DataLoader(async (postIds: readonly string[]) => {
      const counts = await db.comments.countByPostIds([...postIds]);
      const countMap = new Map(counts.map((c) => [c.postId, c.count]));
      return postIds.map((id) => countMap.get(id) ?? 0);
    }),
  };
}

// Resolvers using DataLoader
const resolvers = {
  Query: {
    user: async (_parent: unknown, { id }: { id: string }, ctx: Context) => {
      return ctx.loaders.userLoader.load(id);
    },

    users: async (_parent: unknown, args: UsersArgs, ctx: Context) => {
      const { filter, first, after, orderBy } = args;
      return paginateUsers(ctx.db, { filter, first, after, orderBy });
    },
  },

  Post: {
    // DataLoader resolves N+1 β€” all author loads in one query batch
    author: (post: Post, _args: unknown, ctx: Context) => {
      return ctx.loaders.userLoader.load(post.authorId);
    },

    comments: async (post: Post, args: PaginationArgs, ctx: Context) => {
      return paginateComments(ctx.db, post.id, args);
    },
  },

  Mutation: {
    createPost: async (_parent: unknown, { input }: { input: CreatePostInput }, ctx: Context) => {
      if (!ctx.currentUser) {
        return { __typename: "AuthorizationError", message: "Not authenticated" };
      }

      const errors = validateCreatePost(input);
      if (errors.length > 0) {
        return { __typename: "ValidationError", ...errors[0] };
      }

      const post = await ctx.db.posts.create({
        ...input,
        authorId: ctx.currentUser.id,
        status: "DRAFT",
      });

      return { __typename: "CreatePostSuccess", post };
    },
  },

  // Union type resolution
  CreatePostResult: {
    __resolveType(obj: any) {
      if (obj.post) return "CreatePostSuccess";
      if (obj.field) return "ValidationError";
      return "AuthorizationError";
    },
  },
};

Cursor-Based Pagination

// Generic pagination helper
interface PaginationArgs {
  first?: number;
  after?: string;
}

interface Connection<T> {
  edges: Array<{ cursor: string; node: T }>;
  pageInfo: {
    hasNextPage: boolean;
    hasPreviousPage: boolean;
    startCursor: string | null;
    endCursor: string | null;
  };
  totalCount: number;
}

function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString("base64");
}

function decodeCursor(cursor: string): string {
  const decoded = Buffer.from(cursor, "base64").toString("utf-8");
  return decoded.replace("cursor:", "");
}

async function paginate<T extends { id: string }>(
  query: QueryBuilder<T>,
  args: PaginationArgs
): Promise<Connection<T>> {
  const limit = Math.min(args.first ?? 20, 100); // Cap at 100

  if (args.after) {
    const afterId = decodeCursor(args.after);
    query = query.where("id", ">", afterId);
  }

  const [items, totalCount] = await Promise.all([
    query.orderBy("id").limit(limit + 1).execute(),
    query.count(),
  ]);

  const hasNextPage = items.length > limit;
  const nodes = hasNextPage ? items.slice(0, limit) : items;

  return {
    edges: nodes.map((node) => ({
      cursor: encodeCursor(node.id),
      node,
    })),
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!args.after,
      startCursor: nodes.length > 0 ? encodeCursor(nodes[0].id) : null,
      endCursor: nodes.length > 0 ? encodeCursor(nodes[nodes.length - 1].id) : null,
    },
    totalCount,
  };
}

Subscriptions

import { PubSub, withFilter } from "graphql-subscriptions";

const pubsub = new PubSub();

// Schema
const typeDefs = `
  type Subscription {
    postPublished(authorId: ID): Post!
    commentAdded(postId: ID!): Comment!
  }
`;

// Resolvers
const resolvers = {
  Subscription: {
    postPublished: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["POST_PUBLISHED"]),
        (payload, variables) => {
          if (!variables.authorId) return true;
          return payload.postPublished.authorId === variables.authorId;
        }
      ),
    },

    commentAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator(["COMMENT_ADDED"]),
        (payload, variables) => {
          return payload.commentAdded.postId === variables.postId;
        }
      ),
    },
  },

  Mutation: {
    publishPost: async (_parent, { id }, ctx) => {
      const post = await ctx.db.posts.publish(id);

      // Emit subscription event
      pubsub.publish("POST_PUBLISHED", { postPublished: post });

      return { __typename: "PublishPostSuccess", post };
    },
  },
};

// For production, use Redis PubSub:
// import { RedisPubSub } from "graphql-redis-subscriptions";
// const pubsub = new RedisPubSub({ connection: redisOptions });

Query Complexity and Rate Limiting

import { createComplexityLimitRule } from "graphql-validation-complexity";
import depthLimit from "graphql-depth-limit";

// Apply validation rules
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(10),  // Max query depth
    createComplexityLimitRule(1000, {
      // Cost per field
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,

      // Custom field costs
      formatErrorMessage: (cost: number) =>
        `Query too complex: cost ${cost} exceeds maximum 1000`,
    }),
  ],
});

// Schema directives for field-level cost
// directive @cost(value: Int!) on FIELD_DEFINITION
// directive @listSize(max: Int!) on FIELD_DEFINITION
//
// type Query {
//   users(first: Int = 20): UserConnection! @cost(value: 5) @listSize(max: 100)
//   search(query: String!): [SearchResult!]! @cost(value: 10)
// }

Federation (Apollo Federation v2)

# Users subgraph
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable"])

type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

type Query {
  user(id: ID!): User
}

# Posts subgraph β€” extends User
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@external"])

type User @key(fields: "id") {
  id: ID! @external
  posts(first: Int = 10, after: String): PostConnection!
}

type Post @key(fields: "id") {
  id: ID!
  title: String!
  author: User!
}
// Reference resolver for federated entity
const resolvers = {
  User: {
    __resolveReference: async (ref: { id: string }, ctx: Context) => {
      return ctx.loaders.userLoader.load(ref.id);
    },
  },
};

Caching Strategies

# Cache control directives
type Post @cacheControl(maxAge: 60) {
  id: ID!
  title: String! @cacheControl(maxAge: 300)
  content: String! @cacheControl(maxAge: 300)
  viewCount: Int! @cacheControl(maxAge: 0)  # Never cache
  author: User!
}
// Response caching with Apollo
import responseCachePlugin from "@apollo/server-plugin-response-cache";

const server = new ApolloServer({
  plugins: [
    responseCachePlugin({
      sessionId: (ctx) => ctx.request.http?.headers.get("authorization") ?? null,
    }),
  ],
});

// Application-level caching in resolvers
const resolvers = {
  Query: {
    popularPosts: async (_parent, _args, ctx) => {
      const cacheKey = "popular-posts";
      const cached = await ctx.cache.get(cacheKey);
      if (cached) return JSON.parse(cached);

      const posts = await ctx.db.posts.findPopular(20);
      await ctx.cache.set(cacheKey, JSON.stringify(posts), { ttl: 300 });
      return posts;
    },
  },
};

Authorization Patterns

// Directive-based auth
// directive @auth(requires: UserRole!) on FIELD_DEFINITION
//
// type Mutation {
//   deleteUser(id: ID!): DeleteUserResult! @auth(requires: ADMIN)
//   updateProfile(input: UpdateProfileInput!): User! @auth(requires: VIEWER)
// }

// Field-level authorization in resolvers
const resolvers = {
  User: {
    email: (user: User, _args: unknown, ctx: Context) => {
      // Only show email to the user themselves or admins
      if (ctx.currentUser?.id === user.id || ctx.currentUser?.role === "ADMIN") {
        return user.email;
      }
      return null; // Field is nullable, return null for unauthorized
    },
  },
};

// Middleware-style auth check
function requireAuth(resolver: GraphQLFieldResolver<any, Context>) {
  return (parent: any, args: any, ctx: Context, info: GraphQLResolveInfo) => {
    if (!ctx.currentUser) {
      throw new GraphQLError("Authentication required", {
        extensions: { code: "UNAUTHENTICATED" },
      });
    }
    return resolver(parent, args, ctx, info);
  };
}

Common Anti-Patterns

# ❌ BAD: Exposing database structure directly
type User {
  user_id: Int!           # DB column names
  created_at: String!     # String instead of DateTime
  password_hash: String!  # Sensitive data exposed
}

# βœ… GOOD: Domain-driven types
type User {
  id: ID!
  createdAt: DateTime!
  # Never expose password_hash
}

# ❌ BAD: Offset pagination for large datasets
type Query {
  users(page: Int, pageSize: Int): [User!]!  # Skips are expensive
}

# βœ… GOOD: Cursor-based pagination
type Query {
  users(first: Int, after: String): UserConnection!
}

# ❌ BAD: Generic catch-all types
type Response {
  success: Boolean!
  message: String
  data: JSON  # Untyped blob
}

# βœ… GOOD: Typed union results
union CreateUserResult = CreateUserSuccess | ValidationError | ConflictError
// ❌ BAD: N+1 in resolvers (one query per post author)
Post: {
  author: (post) => db.users.findById(post.authorId)  // Called N times!
}

// βœ… GOOD: DataLoader batches into single query
Post: {
  author: (post, _, ctx) => ctx.loaders.userLoader.load(post.authorId)
}

Adapted from buildwithclaude by Dave Poon (MIT)

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.