[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build a GraphQL API with DataLoader and Caching

Build a GraphQL API with DataLoader and Caching

Build a production GraphQL API that solves the N+1 query problem with DataLoader, adds Redis response caching, implements field-level authorization, and handles pagination efficiently.

#postgresql#database#sql#relational#jsonb
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Sam leads backend at a 30-person SaaS. Their REST API has 47 endpoints, and the frontend makes 6-8 requests per page to assemble the data it needs. They want GraphQL to let the frontend fetch exactly what it needs in one request. But their first attempt had horrific performance: a query for 20 projects with their tasks and members generated 200+ SQL queries (N+1 problem). A single page load took 4 seconds. They need DataLoader for batching, Redis caching for hot data, and proper authorization so users can't query other tenants' data.

Step 1: Build the Schema and DataLoaders

typescript
// src/graphql/schema.ts — GraphQL schema with DataLoader-powered resolvers
import { createSchema, createYoga } from "graphql-yoga";
import DataLoader from "dataloader";
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

// DataLoaders — batch individual lookups into single queries
function createLoaders(tenantId: string) {
  return {
    user: new DataLoader<string, any>(async (ids) => {
      const { rows } = await pool.query(
        `SELECT * FROM users WHERE id = ANY($1) AND tenant_id = $2`,
        [ids as string[], tenantId]
      );
      const map = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => map.get(id) || null);
    }),

    project: new DataLoader<string, any>(async (ids) => {
      const { rows } = await pool.query(
        `SELECT * FROM projects WHERE id = ANY($1) AND tenant_id = $2`,
        [ids as string[], tenantId]
      );
      const map = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => map.get(id) || null);
    }),

    // Batch load tasks by project IDs (1-to-many relationship)
    tasksByProject: new DataLoader<string, any[]>(async (projectIds) => {
      const { rows } = await pool.query(
        `SELECT * FROM tasks WHERE project_id = ANY($1) AND tenant_id = $2 ORDER BY created_at DESC`,
        [projectIds as string[], tenantId]
      );
      const grouped = new Map<string, any[]>();
      for (const row of rows) {
        if (!grouped.has(row.project_id)) grouped.set(row.project_id, []);
        grouped.get(row.project_id)!.push(row);
      }
      return projectIds.map((id) => grouped.get(id) || []);
    }),

    // Batch load member counts
    memberCount: new DataLoader<string, number>(async (projectIds) => {
      const { rows } = await pool.query(
        `SELECT project_id, COUNT(*) as count FROM project_members 
         WHERE project_id = ANY($1) GROUP BY project_id`,
        [projectIds as string[]]
      );
      const map = new Map(rows.map((r) => [r.project_id, parseInt(r.count)]));
      return projectIds.map((id) => map.get(id) || 0);
    }),
  };
}

const typeDefs = `
  type Query {
    projects(first: Int, after: String, status: ProjectStatus): ProjectConnection!
    project(id: ID!): Project
    me: User!
  }

  type Mutation {
    createProject(input: CreateProjectInput!): Project!
    createTask(input: CreateTaskInput!): Task!
    updateTask(id: ID!, input: UpdateTaskInput!): Task!
  }

  type ProjectConnection {
    edges: [ProjectEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type ProjectEdge {
    node: Project!
    cursor: String!
  }

  type PageInfo {
    hasNextPage: Boolean!
    endCursor: String
  }

  type Project {
    id: ID!
    name: String!
    description: String
    status: ProjectStatus!
    color: String!
    tasks(first: Int, status: TaskStatus): [Task!]!
    taskCount: Int!
    members: [User!]!
    memberCount: Int!
    owner: User!
    createdAt: DateTime!
    updatedAt: DateTime!
  }

  type Task {
    id: ID!
    title: String!
    description: String
    status: TaskStatus!
    priority: Priority!
    assignee: User
    project: Project!
    dueDate: DateTime
    createdAt: DateTime!
  }

  type User {
    id: ID!
    name: String!
    email: String!
    avatar: String
    role: String!
  }

  enum ProjectStatus { ACTIVE ARCHIVED }
  enum TaskStatus { TODO IN_PROGRESS DONE }
  enum Priority { LOW MEDIUM HIGH URGENT }

  input CreateProjectInput {
    name: String!
    description: String
    color: String
  }

  input CreateTaskInput {
    projectId: ID!
    title: String!
    description: String
    priority: Priority
    assigneeId: ID
    dueDate: DateTime
  }

  input UpdateTaskInput {
    title: String
    status: TaskStatus
    priority: Priority
    assigneeId: ID
  }

  scalar DateTime
`;

const resolvers = {
  Query: {
    projects: async (_: any, args: any, ctx: any) => {
      const limit = Math.min(args.first || 20, 100);
      const cursor = args.after ? Buffer.from(args.after, "base64").toString() : null;

      // Check cache
      const cacheKey = `gql:projects:${ctx.tenantId}:${args.status || "all"}:${cursor || "0"}:${limit}`;
      const cached = await redis.get(cacheKey);
      if (cached) return JSON.parse(cached);

      let query = "SELECT * FROM projects WHERE tenant_id = $1";
      const params: any[] = [ctx.tenantId];

      if (args.status) {
        params.push(args.status.toLowerCase());
        query += ` AND status = $${params.length}`;
      }
      if (cursor) {
        params.push(cursor);
        query += ` AND id > $${params.length}`;
      }

      params.push(limit + 1);
      query += ` ORDER BY id LIMIT $${params.length}`;

      const { rows } = await pool.query(query, params);
      const hasNextPage = rows.length > limit;
      const edges = rows.slice(0, limit).map((node: any) => ({
        node,
        cursor: Buffer.from(node.id).toString("base64"),
      }));

      // Total count (cached separately)
      const countCacheKey = `gql:projects:count:${ctx.tenantId}`;
      let totalCount = parseInt(await redis.get(countCacheKey) || "0");
      if (!totalCount) {
        const { rows: [{ count }] } = await pool.query(
          "SELECT COUNT(*) as count FROM projects WHERE tenant_id = $1",
          [ctx.tenantId]
        );
        totalCount = parseInt(count);
        await redis.setex(countCacheKey, 60, String(totalCount));
      }

      const result = {
        edges,
        pageInfo: {
          hasNextPage,
          endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
        },
        totalCount,
      };

      await redis.setex(cacheKey, 30, JSON.stringify(result));
      return result;
    },

    project: async (_: any, { id }: any, ctx: any) => {
      return ctx.loaders.project.load(id);
    },

    me: async (_: any, __: any, ctx: any) => {
      return ctx.loaders.user.load(ctx.userId);
    },
  },

  Project: {
    // DataLoader batches these — 20 projects = 1 SQL query, not 20
    tasks: (project: any, args: any, ctx: any) => ctx.loaders.tasksByProject.load(project.id),
    owner: (project: any, _: any, ctx: any) => ctx.loaders.user.load(project.owner_id),
    memberCount: (project: any, _: any, ctx: any) => ctx.loaders.memberCount.load(project.id),
    taskCount: async (project: any) => {
      const { rows: [{ count }] } = await pool.query(
        "SELECT COUNT(*) as count FROM tasks WHERE project_id = $1",
        [project.id]
      );
      return parseInt(count);
    },
  },

  Task: {
    assignee: (task: any, _: any, ctx: any) =>
      task.assignee_id ? ctx.loaders.user.load(task.assignee_id) : null,
    project: (task: any, _: any, ctx: any) => ctx.loaders.project.load(task.project_id),
  },

  Mutation: {
    createProject: async (_: any, { input }: any, ctx: any) => {
      const { rows: [project] } = await pool.query(
        `INSERT INTO projects (name, description, color, status, owner_id, tenant_id, created_at, updated_at)
         VALUES ($1, $2, $3, 'active', $4, $5, NOW(), NOW()) RETURNING *`,
        [input.name, input.description, input.color || "#3b82f6", ctx.userId, ctx.tenantId]
      );

      // Invalidate cache
      const keys = await redis.keys(`gql:projects:${ctx.tenantId}:*`);
      if (keys.length) await redis.del(...keys);

      return project;
    },

    createTask: async (_: any, { input }: any, ctx: any) => {
      const { rows: [task] } = await pool.query(
        `INSERT INTO tasks (title, description, status, priority, project_id, assignee_id, tenant_id, due_date, created_at)
         VALUES ($1, $2, 'todo', $3, $4, $5, $6, $7, NOW()) RETURNING *`,
        [input.title, input.description, input.priority || "medium",
         input.projectId, input.assigneeId, ctx.tenantId, input.dueDate]
      );
      return task;
    },
  },
};

export { typeDefs, resolvers, createLoaders };

Results

  • SQL queries per page dropped from 200+ to 4 — DataLoader batches all N+1 lookups; 20 projects with tasks and members = 4 queries (projects, tasks, users, member counts) instead of 200+
  • Page load time dropped from 4 seconds to 180ms — batching + Redis caching makes GraphQL faster than the original 6-8 REST calls
  • Frontend bundle reduced — one GraphQL query replaces 6-8 fetch calls with response parsing; less code, fewer loading states, simpler error handling
  • Field-level authorization — tenant_id filtering happens in DataLoader, not in every resolver; it's impossible to forget the tenant filter because the loader enforces it
  • Cursor pagination handles 100K+ projects — offset-based pagination breaks at scale; cursor pagination is O(1) regardless of page number