mcpField
G
graphql-design

graphql-design

github.com/rohitg00開発2.8k180 回インストール24 週間前に更新
リポジトリ

graphql-design について

graphql-designは、GraphQLのスキーマやリゾルバーを設計する開発者向けのスキルです。ページネーション、変更ペイロード、DataLoader、サブスクリプションと安全対策を扱います。

主な機能

  1. 01Relay形式のカーソルページネーション
  2. 02結果とエラーを含む変更ペイロード
  3. 03リクエスト単位のDataLoaderによるN+1対策
  4. 04サブスクリプションのパターン
  5. 05クエリ深度と複雑度の制限
  6. 06モジュール型スキーマ設計

活用シーン

  1. 01リストフィールドのページネーション設計
  2. 02エンティティ検索の重複削減
  3. 03変更エラー構造の設計
  4. 04フィルター付きイベント購読の設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rohitg00/awesome-claude-code-toolkit --skill graphql-design

インストール後、Claude Code で/graphql-designを入力して読み込まれたか確認します。

詳細

GraphQL 設計

スキーマ設計

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

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

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

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

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

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

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

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

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

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

ページネーションには Relay スタイルのコネクションを使用します。ミューテーションでは、結果とエラーの両方を含むペイロード型を返します。

リゾルバー

const resolvers: Resolvers = {
  Query: {
    user: async (_, { id }, ctx) => {
      return ctx.dataloaders.user.load(id);
    },
    users: async (_, { filter, first, after }, ctx) => {
      const cursor = after ? decodeCursor(after) : undefined;
      const users = await ctx.db.user.findMany({
        where: buildFilter(filter),
        take: first + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: "desc" },
      });

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

      return {
        edges,
        pageInfo: {
          hasNextPage,
          endCursor: edges[edges.length - 1]?.cursor ?? null,
        },
      };
    },
  },

  Mutation: {
    createUser: async (_, { input }, ctx) => {
      const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
      if (existing) {
        return { user: null, errors: [{ field: "email", message: "Already taken" }] };
      }
      const user = await ctx.db.user.create({ data: input });
      return { user, errors: [] };
    },
  },

  User: {
    orders: async (parent, { first, after }, ctx) => {
      return ctx.dataloaders.userOrders.load({ userId: parent.id, first, after });
    },
  },
};

N+1 の防止に DataLoader を使用する

import DataLoader from "dataloader";

function createLoaders(db: Database) {
  return {
    user: new DataLoader<string, User>(async (ids) => {
      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) ?? new Error(`User ${id} not found`));
    }),

    userOrders: new DataLoader<{ userId: string }, Order[]>(async (keys) => {
      const userIds = keys.map(k => k.userId);
      const orders = await db.order.findMany({
        where: { userId: { in: userIds } },
        orderBy: { createdAt: "desc" },
      });
      const grouped = new Map<string, Order[]>();
      orders.forEach(o => {
        const list = grouped.get(o.userId) ?? [];
        list.push(o);
        grouped.set(o.userId, list);
      });
      return keys.map(k => grouped.get(k.userId) ?? []);
    }),
  };
}

ユーザー間で古いキャッシュが共有されないよう、リクエストごとに新しい DataLoader インスタンスを作成します。

サブスクリプション

const pubsub = new PubSub();

const resolvers = {
  Subscription: {
    orderStatusChanged: {
      subscribe: (_, { orderId }) => {
        return pubsub.asyncIterableIterator(`ORDER_STATUS_${orderId}`);
      },
    },
  },
  Mutation: {
    updateOrderStatus: async (_, { id, status }, ctx) => {
      const order = await ctx.db.order.update({ where: { id }, data: { status } });
      await pubsub.publish(`ORDER_STATUS_${id}`, { orderStatusChanged: order });
      return { order, errors: [] };
    },
  },
};

アンチパターン

  • データベーススキーマを GraphQL スキーマとしてそのまま公開する
  • DataLoader を使わずにネストされたフィールドを解決する(N+1 クエリが発生します)
  • 大規模なデータセットで、カーソルベースではなくオフセットベースのページネーションを使用する
  • 型付きエラーペイロードを返さず、リゾルバーから生のエラーをスローする
  • 型定義をモジュール化せず、単一の巨大なスキーマファイルを作成する
  • 深度や複雑度の制限を設けず、無制限のクエリを許可する

チェックリスト

  • すべてのリストフィールドで Relay スタイルのカーソルページネーションを使用する
  • バッチ処理するすべてのエンティティ参照に DataLoader を使用する
  • ミューテーションから、結果フィールドとエラーフィールドの両方を含むペイロード型を返す
  • ミューテーションの引数に入力型を使用する
  • クエリの深度と複雑度の制限を設定する
  • コンテキスト内でリクエストごとに DataLoader インスタンスを作成する
  • スキーマをドメイン固有のモジュールに分割する
  • すべてのクライアントへのブロードキャストを避けるため、サブスクリプションでフィルター済みのトピックを使用する

コメント