Skip to content
Back to skills

Graphql Patterns

ASecurity

GraphQL API patterns: schema-first design, resolvers, DataLoader for N+1 prevention, subscriptions, error handling, pagination, auth, and performance. For TypeScript with Apollo Server or Pothos.

  • 4 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 12, 2026
developmenttypescriptnodeapidatabasefrontendperformance

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 12, 2026

npx -y skills add aibot88/sec_skill_store --skill graphql-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Graphql Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Graphql Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/aibot88-graphql-patterns/badge)](https://www.skillsdirectory.com/skills/aibot88-graphql-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: graphql-patterns
description: "GraphQL API patterns: schema-first design, resolvers, DataLoader for N+1 prevention, subscriptions, error handling, pagination, auth, and performance. For TypeScript with Apollo Server or Pothos."
---

# GraphQL Patterns Skill

## When to Activate

- Building a GraphQL API (new or adding to existing)
- Solving N+1 query problems in resolvers
- Implementing real-time subscriptions
- Designing a type-safe schema with code generation
- Adding auth (field-level or operation-level) to GraphQL
- Migrating list endpoints from plain arrays to Relay-style cursor-paginated connections
- Choosing between Apollo Server with SDL-first resolvers and Pothos code-first schema builders
- Configuring query depth limits and complexity scoring to protect against expensive client-sent queries

---

## Schema-First Design

Write the schema first — it's your contract.

```graphql
# schema.graphql

type Query {
  user(id: ID!): User
  users(first: Int = 10, after: String): UserConnection!
}

type Mutation {
  createUser(input: CreateUserInput!): UserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UserPayload!
}

type Subscription {
  userCreated: User!
}

type User {
  id: ID!
  email: String!
  name: String!
  orders(first: Int = 10): OrderConnection!
  createdAt: DateTime!
}

# Always use Relay-style Connections for lists (enables cursor pagination)
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

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

# Mutation payloads: always return the mutated entity + errors
type UserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
  code: String!
}

input CreateUserInput {
  email: String!
  name: String!
}

scalar DateTime
```

---

## Schema with Pothos (TypeScript, type-safe)

```typescript
// schema/builder.ts
import SchemaBuilder from '@pothos/core';
import DataloaderPlugin from '@pothos/plugin-dataloader';
import RelayPlugin from '@pothos/plugin-relay';
import ScopeAuthPlugin from '@pothos/plugin-scope-auth';

export const builder = new SchemaBuilder<{
  Context: GraphQLContext;
  AuthScopes: { authenticated: boolean; admin: boolean };
}>({
  plugins: [ScopeAuthPlugin, RelayPlugin, DataloaderPlugin],
  authScopes: async (context) => ({
    authenticated: !!context.user,
    admin: context.user?.role === 'admin',
  }),
  relay: {},
});

// schema/types/user.ts
builder.queryField('user', (t) =>
  t.field({
    type: UserType,
    nullable: true,
    authScopes: { authenticated: true },
    args: { id: t.arg.id({ required: true }) },
    resolve: (_, { id }, ctx) => ctx.loaders.user.load(id),
  })
);
```

---

## DataLoader — Solving the N+1 Problem

Without DataLoader, fetching 100 users' orders = 101 DB queries (1 + 100).

```typescript
// loaders/index.ts
import DataLoader from 'dataloader';

export function createLoaders(db: Database) {
  return {
    // Batch: instead of 100 individual queries, one WHERE IN query
    user: new DataLoader<string, User | null>(async (ids) => {
      const users = await db.query.users.findMany({
        where: inArray(usersTable.id, ids as string[]),
      });
      // MUST return results in same order as input ids
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    }),

    // Orders by userId: batch load
    ordersByUser: new DataLoader<string, Order[]>(async (userIds) => {
      const orders = await db.query.orders.findMany({
        where: inArray(ordersTable.userId, userIds as string[]),
      });
      // Group by userId
      const grouped = new Map<string, Order[]>();
      for (const order of orders) {
        const list = grouped.get(order.userId) ?? [];
        list.push(order);
        grouped.set(order.userId, list);
      }
      return userIds.map(id => grouped.get(id) ?? []);
    }),
  };
}

// Context setup (per request)
export function createContext(req: Request): GraphQLContext {
  return {
    user: req.user,
    db,
    loaders: createLoaders(db),  // Fresh loaders per request (clear between requests)
  };
}
```

---

## Cursor Pagination

```typescript
// Relay-compliant cursor pagination
async function paginateUsers(args: {
  first?: number;
  after?: string;
}): Promise<UserConnection> {
  const limit = Math.min(args.first ?? 10, 100); // cap at 100
  const afterId = args.after ? decodeCursor(args.after) : null;

  const rows = await db.query.users.findMany({
    where: afterId ? gt(users.id, afterId) : undefined,
    limit: limit + 1,  // fetch one extra to determine hasNextPage
    orderBy: asc(users.id),
  });

  const hasNextPage = rows.length > limit;
  const edges = rows.slice(0, limit).map(user => ({
    node: user,
    cursor: encodeCursor(user.id),
  }));

  return {
    edges,
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!afterId,
      startCursor: edges[0]?.cursor ?? null,
      endCursor: edges[edges.length - 1]?.cursor ?? null,
    },
    totalCount: await db.$count(users),
  };
}

function encodeCursor(id: string) {
  return Buffer.from(`cursor:${id}`).toString('base64');
}
function decodeCursor(cursor: string) {
  return Buffer.from(cursor, 'base64').toString().replace('cursor:', '');
}
```

---

## Error Handling

```typescript
// GraphQL errors: use extensions for machine-readable codes
import { GraphQLError } from 'graphql';

// Throw typed errors from resolvers
throw new GraphQLError('User not found', {
  extensions: {
    code: 'NOT_FOUND',
    http: { status: 404 },
  },
});

// Mutation payloads: return errors as data (not thrown)
// This allows partial success and field-level errors
const createUser = async (input: CreateUserInput): Promise<UserPayload> => {
  const validation = validateUserInput(input);
  if (!validation.ok) {
    return {
      user: null,
      errors: validation.errors.map(e => ({
        field: e.field,
        message: e.message,
        code: 'VALIDATION_ERROR',
      })),
    };
  }

  const user = await db.insert(users).values(input).returning();
  return { user, errors: [] };
};
```

---

## Subscriptions

```typescript
// Apollo Server with WebSocket transport
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';

const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });

useServer(
  {
    schema,
    context: async (ctx) => {
      // Auth for subscriptions via connection params
      const token = ctx.connectionParams?.authorization;
      const user = token ? await verifyToken(token) : null;
      return { user, loaders: createLoaders(db) };
    },
  },
  wsServer
);

// Resolver with PubSub
const pubsub = new PubSub();

const resolvers = {
  Mutation: {
    createUser: async (_, { input }) => {
      const user = await createUserInDb(input);
      await pubsub.publish('USER_CREATED', { userCreated: user });
      return { user, errors: [] };
    },
  },
  Subscription: {
    userCreated: {
      subscribe: (_, __, ctx) => {
        if (!ctx.user?.isAdmin) throw new GraphQLError('Unauthorized');
        return pubsub.asyncIterableIterator('USER_CREATED');
      },
    },
  },
};
```

---

## Performance

```typescript
// Depth limiting — prevent deeply nested malicious queries
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(10),                    // Max query depth
    createComplexityRule({ maxComplexity: 1000 }),  // query-complexity
  ],
});

// Persisted queries — send hash instead of full query text
// Apollo Client sends { extensions: { persistedQuery: { sha256Hash: "..." } } }
// Server looks up query by hash — reduces bandwidth, enables CDN caching of queries
```

---

## Checklist

- [ ] Schema written first, before any resolver code
- [ ] All list fields use Relay Connection pagination (not plain arrays)
- [ ] DataLoaders created per-request (not per-server) to prevent cross-request data leaking
- [ ] Every list resolver uses DataLoader (zero N+1 queries)
- [ ] Mutation payloads return `errors: []` field (never throw for user errors)
- [ ] Auth checked at operation level AND field level where needed
- [ ] Query depth limit configured (max 10)
- [ ] Subscriptions auth via connectionParams (not cookies)
- [ ] `graphql-codegen` generates types from schema for frontend consumption

Files in this skill

  • SKILL.md8.4 KB
  • source.json714 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.