graphql-api-design について
graphql-api-design は、仕様・スキーマ・自然言語の説明から GraphQL API を設計する AI エージェント向けスキルです。API 開発者を対象に、スキーマ定義とリゾルバー実装を生成し、ページネーションや性能・セキュリティ対策も扱います。
主な機能
- 01オブジェクト・入力型・列挙型の設計
- 02クエリ・ミューテーション・サブスクリプションの設計
- 03Relay カーソルページネーション構造の生成
- 04DataLoader による N+1 問題の緩和
- 05深度と複雑度の制限
- 06スキーマフェデレーションの設計
活用シーン
- 01ドメインの説明から GraphQL スキーマを生成
- 02API のクエリやミューテーション用リゾルバーを実装
- 03リストフィールドのカーソルページネーションを設計
- 04関連データ取得を最適化し、過剰なクエリを制限
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ seb1n/ awesome-ai-agent-skills --skill graphql-api-designインストール後、Claude Code で/graphql-api-designを入力して読み込まれたか確認します。
詳細
GraphQL API の設計
このスキルを使うと、AI エージェントが仕様、スキーマ、または自然言語による説明から、完全な GraphQL API を設計できます。エージェントは型定義、クエリ、ミューテーション、サブスクリプション、入力型、列挙型、およびリゾルバーの実装を生成します。N+1 問題を防ぐ DataLoader、Relay の Connection 仕様によるカーソルベースのページネーション、クエリ深度の制限、マイクロサービスアーキテクチャ向けのスキーマフェデレーションなど、パフォーマンス向上のパターンも適用します。
ワークフロー
-
ドメインを型としてモデル化します: アプリケーションのドメインを分析し、GraphQL のオブジェクト型、入力型、列挙型、インターフェース、ユニオンを定義します。各型は実際のエンティティを表し、データの利用者が実際に必要とするフィールドを含める必要があります。非 null(
!)の注釈は慎重に使い、実際に値が存在しない場合があるフィールドは nullable にします。スキーマを自己記述的にするため、生のStringよりも具体的なスカラー型(例:DateTime、URL)を優先します。 -
クエリとミューテーションを設計します: 読み取り操作には Query フィールドを、書き込み操作には Mutation フィールドを定義します。クエリには名詞ベースの名前(
user、posts)を使い、ミューテーションには動詞ベースの名前(createPost、updateUser)を使います。各ミューテーションは単一の入力型引数を受け取り、変更されたオブジェクトとユーザー向けのエラーを含むペイロード型を返すようにします。このパターンにより、ミューテーションの一貫性と拡張性が保たれます。 -
Connection を使ってページネーションを実装します: 多数の項目を返す可能性があるリストフィールドには、
edges、node、cursor、pageInfoを含む Relay Connection 仕様を使います。これにより、オフセットベースのページネーションとは異なり、挿入や削除があっても安定したカーソルベースのページネーションが可能になります。生の配列を返すのではなく、エンティティごとに再利用可能な Connection 型を定義します。 -
DataLoader を使ってリゾルバーを記述します: 1 回のリクエスト内でデータベースの検索をまとめてキャッシュする DataLoader を使うリゾルバーを実装します。DataLoader がない場合、50 件の投稿とその作成者を取得するクエリでは、作成者を検索するクエリが 50 回個別に実行されます(N+1 問題)。DataLoader はこれらを 1 回のバッチクエリにまとめます。ユーザー間でデータが漏洩しないよう、リクエストごとに新しい DataLoader インスタンスを作成します。
-
リアルタイムデータ用のサブスクリプションを追加します: クライアントがリアルタイムで反応する必要のあるイベント(例:新しいメッセージ、ステータスの変更)に対して Subscription フィールドを定義します。イベントの配信には、Pub/Sub バックエンド(Redis、Kafka、または開発用のインメモリ)を使います。サブスクリプションのペイロードは最小限に抑えます。必要に応じて、クライアントはサブスクリプションをトリガーとして全データを再取得できます。
-
スキーマを保護し、最適化します: 悪意のあるクエリを防ぐため、クエリ深度の制限(最大 10~15 レベル)とクエリ複雑度の分析を追加します。リゾルバーでフィールド単位の認可を実装します。本番環境では、帯域幅を削減し、任意のクエリ実行を防ぐために、永続化クエリを使います。API が複数のサービスにまたがる場合は、スキーマフェデレーションを検討します。
対応テクノロジー
- サーバー: Apollo Server、GraphQL Yoga、Mercurius(Fastify)、Strawberry(Python)、graphql-java
- スキーマツール: SDL ファースト(typeDefs)、コードファースト(TypeGraphQL、Nexus、Pothos)
- パフォーマンス: DataLoader、@defer/@stream ディレクティブ、永続化クエリ、自動永続化クエリ(APQ)
- フェデレーション: Apollo Federation、GraphQL Mesh、スキーマスティッチング
- テスト: GraphQL Playground、Apollo Studio、graphql-test(jest)、Insomnia
使用方法
データエンティティ、その関係、および必要な操作についての説明をエージェントに提供します。エージェントは完全な SDL スキーマ、リゾルバーの実装、DataLoader の設定を生成します。SDL ファーストとコードファーストのどちらの出力にするか、また対象とするサーバーフレームワークを指定してください。
例
例 1:リゾルバーを備えたブログプラットフォームのスキーマ
# schema.graphql — Complete blog platform schema
scalar DateTime
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type User {
id: ID!
username: String!
email: String!
bio: String
avatarUrl: String
posts(first: Int, after: String): PostConnection!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
status: PostStatus!
author: User!
tags: [Tag!]!
comments(first: Int, after: String): CommentConnection!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
createdAt: DateTime!
}
type Tag {
id: ID!
name: String!
slug: String!
posts(first: Int, after: String): PostConnection!
}
# Relay connection types for cursor-based pagination
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type CommentConnection {
edges: [CommentEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type CommentEdge {
cursor: String!
node: Comment!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Queries
type Query {
post(id: ID, slug: String): Post
posts(
first: Int = 10
after: String
status: PostStatus
tagSlug: String
): PostConnection!
user(id: ID!): User
me: User
tags: [Tag!]!
}
# Mutations with input types and payload types
input CreatePostInput {
title: String!
content: String!
tagIds: [ID!]
status: PostStatus = DRAFT
}
type CreatePostPayload {
post: Post
errors: [MutationError!]!
}
input UpdatePostInput {
title: String
content: String
status: PostStatus
tagIds: [ID!]
}
type UpdatePostPayload {
post: Post
errors: [MutationError!]!
}
type MutationError {
field: String
message: String!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
deletePost(id: ID!): Boolean!
addComment(postId: ID!, body: String!): Comment!
}
# Subscriptions
type Subscription {
commentAdded(postId: ID!): Comment!
postPublished: Post!
}
// resolvers.js — Resolvers with DataLoader for N+1 prevention
const DataLoader = require("dataloader");
// Create loaders per request (called from context factory)
function createLoaders(db) {
return {
userLoader: new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
}),
postLoader: new DataLoader(async (postIds) => {
const posts = await db.posts.findByIds(postIds);
const postMap = new Map(posts.map((p) => [p.id, p]));
return postIds.map((id) => postMap.get(id) || null);
}),
};
}
const resolvers = {
Query: {
post: (_, { id, slug }, { db }) => {
if (id) return db.posts.findById(id);
if (slug) return db.posts.findBySlug(slug);
return null;
},
posts: async (_, { first = 10, after, status, tagSlug }, { db }) => {
const cursor = after ? decodeCursor(after) : null;
const { rows, totalCount } = await db.posts.findPaginated({
limit: first + 1,
cursor,
status,
tagSlug,
});
const hasNextPage = rows.length > first;
const edges = rows.slice(0, first).map((post) => ({
cursor: encodeCursor(post.id),
node: post,
}));
return {
edges,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
};
},
me: (_, __, { currentUser }) => currentUser,
},
Post: {
author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
tags: (post, _, { db }) => db.tags.findByPostId(post.id),
},
Comment: {
author: (comment, _, { loaders }) => loaders.userLoader.load(comment.authorId),
},
Mutation: {
createPost: async (_, { input }, { currentUser, db }) => {
if (!currentUser) return { post: null, errors: [{ message: "Not authenticated" }] };
if (!input.title.trim()) {
return { post: null, errors: [{ field: "title", message: "Title cannot be empty" }] };
}
const post = await db.posts.create({ ...input, authorId: currentUser.id });
return { post, errors: [] };
},
},
};
function encodeCursor(id) { return Buffer.from(`cursor:${id}`).toString("base64"); }
function decodeCursor(cursor) { return Buffer.from(cursor, "base64").toString().replace("cursor:", ""); }
例 2:カーソルベースのページネーションの実装
// pagination.js — Reusable cursor-based pagination for any entity
/**
* Generic paginated query builder for SQL databases.
* Works with any table that has an auto-incrementing or sortable ID.
*/
async function paginatedQuery(db, { table, first = 10, after, where = {} }) {
const limit = Math.min(first, 100); // Cap at 100 per page
const conditions = [];
const params = [];
// Apply cursor (decode to original ID)
if (after) {
const cursorId = Buffer.from(after, "base64").toString().split(":")[1];
conditions.push(`id < $${params.length + 1}`);
params.push(cursorId);
}
// Apply additional filters
for (const [key, value] of Object.entries(where)) {
if (value !== undefined) {
conditions.push(`${key} = $${params.length + 1}`);
params.push(value);
}
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Fetch one extra row to determine hasNextPage
const query = `SELECT * FROM ${table} ${whereClause} ORDER BY id DESC LIMIT ${limit + 1}`;
const rows = await db.query(query, params);
// Count total matching rows
const countQuery = `SELECT COUNT(*) as total FROM ${table} ${whereClause}`;
const [{ total: totalCount }] = await db.query(countQuery, params);
const hasNextPage = rows.length > limit;
const nodes = rows.slice(0, limit);
const edges = nodes.map((node) => ({
cursor: Buffer.from(`cursor:${node.id}`).toString("base64"),
node,
}));
return {
edges,
totalCount,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor || null,
endCursor: edges[edges.length - 1]?.cursor || null,
},
};
}
// Usage in resolver
const resolvers = {
Query: {
posts: (_, args, { db }) =>
paginatedQuery(db, {
table: "posts",
first: args.first,
after: args.after,
where: { status: args.status },
}),
},
};
ベストプラクティス
- ミューテーションの一貫性を保つため、常に単一の
input引数を使用し、結果とユーザー向けエラーのリストの両方を含むペイロード型を返します。これにより、クライアントコードの予測可能性が高まります。 - すべてのリレーションシップリゾルバーで DataLoader を使用して N+1 問題を解決します。 ユーザーやリクエスト間でキャッシュデータが漏えいしないよう、リクエストごとに(コンテキストファクトリーで)DataLoader インスタンスを作成します。
- サービス拒否攻撃を防ぐため、クエリの深さと複雑さを制限します。 最大深度を 10~15 に設定し、フィールド(特にコネクションとネストされたリレーションシップ)に複雑さのコストを割り当てます。
- 単一エンティティのクエリには nullable な戻り値の型(
post(id: ID!): Postは見つからない場合にnullを返します)を使用し、リストクエリには non-nullable な配列(tags: [Tag!]!は空の場合も含め、常に配列を返します)を使用します。 - URL のバージョニングではなく、スキーマの進化によってバージョン管理します。 新しいフィールドは自由に追加し(非破壊的)、古いフィールドは
@deprecated(reason: "Use newField instead")で非推奨にして、クライアントの移行後に削除します。 - 個別のスカラー引数を渡すのではなく、すべてのミューテーション引数に入力型を使用します。 これにより、既存のクライアントを壊すことなく、後からオプションのフィールドを簡単に追加できます。
エッジケース
- 循環参照:
User -> Posts -> Author -> Postsのような型は循環スキーマを作成します。これは GraphQL では有効ですが、無限クエリを防ぐために深度制限が必要です。DataLoader によって無限の解決ループを防止できます。 - null の伝播: non-nullable フィールドのリゾルバーがエラーをスローすると、null は最も近い nullable な親まで伝播します。1 つのフィールドのエラーによってレスポンス全体が null にならないよう、nullable の境界を慎重に設計します。
- 空のコネクション: 結果セットが空の場合は
nullではなく、{ edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false }, totalCount: 0 }を返します。 - カーソルの安定性: カーソルは不透明で、挿入後も安定している必要があります。行 ID をカーソル値として使用する方法(base64 エンコード)は安定していますが、オフセットを使用する方法は安定せず、項目の挿入や削除で機能しなくなります。
- ファイルのアップロード: GraphQL はファイルのアップロードをネイティブにはサポートしていません。マルチパートリクエスト仕様(
graphql-upload)を使用するか、別の REST エンドポイントでアップロードを処理し、結果の URL をミューテーションに渡します。 - サブスクリプション接続の切断: クライアントの WebSocket 接続が失われることがあります。サブスクリプションストリームだけに頼らず、再接続時に再クエリして状態を復元できるよう、サブスクリプションを設計します。