ストーリー
高橋アーキテクトが複雑なスキーマ図を広げた。
スキーマ設計の原則
ドメインモデルの反映
# TaskFlow アプリケーションのスキーマ設計
type User {
id: ID!
name: String!
email: String!
role: UserRole!
projects: [ProjectMembership!]!
createdAt: DateTime!
}
type Project {
id: ID!
name: String!
description: String
owner: User!
members: [ProjectMembership!]!
tasks(status: TaskStatus, limit: Int): [Task!]!
taskCount: Int!
createdAt: DateTime!
}
type ProjectMembership {
user: User!
project: Project!
role: MemberRole!
joinedAt: DateTime!
}
type Task {
id: ID!
title: String!
description: String
status: TaskStatus!
priority: Priority!
project: Project!
assignee: User
labels: [Label!]!
comments(limit: Int): [Comment!]!
commentCount: Int!
dueDate: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
body: String!
author: User!
task: Task!
createdAt: DateTime!
}
type Label {
id: ID!
name: String!
color: String!
}
enum UserRole {
ADMIN
USER
}
enum MemberRole {
OWNER
ADMIN
MEMBER
VIEWER
}
enum TaskStatus {
TODO
IN_PROGRESS
IN_REVIEW
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
CRITICAL
}
scalar DateTime
Query ルート型
type Query {
# ユーザー
me: User!
user(id: ID!): User
# プロジェクト
projects(limit: Int, offset: Int): ProjectConnection!
project(id: ID!): Project
# タスク
task(id: ID!): Task
searchTasks(query: String!, projectId: ID): [Task!]!
}
Mutation ルート型
type Mutation {
# タスクの操作
createTask(input: CreateTaskInput!): CreateTaskPayload!
updateTask(id: ID!, input: UpdateTaskInput!): UpdateTaskPayload!
deleteTask(id: ID!): DeleteTaskPayload!
# コメント
addComment(taskId: ID!, body: String!): AddCommentPayload!
# プロジェクトメンバー
inviteMember(projectId: ID!, userId: ID!, role: MemberRole!): InviteMemberPayload!
}
# Input型(入力データ)
input CreateTaskInput {
projectId: ID!
title: String!
description: String
priority: Priority = MEDIUM
assigneeId: ID
labelIds: [ID!]
dueDate: DateTime
}
input UpdateTaskInput {
title: String
description: String
status: TaskStatus
priority: Priority
assigneeId: ID
labelIds: [ID!]
dueDate: DateTime
}
# Payload型(レスポンス)-- エラーハンドリングに対応
type CreateTaskPayload {
task: Task
errors: [UserError!]!
}
type UserError {
field: String
message: String!
}
リゾルバーの実装
基本的なリゾルバー
// リゾルバーマップ
const resolvers = {
Query: {
me: async (_parent: unknown, _args: unknown, context: Context) => {
// contextから認証済みユーザーを取得
return context.currentUser;
},
project: async (_parent: unknown, args: { id: string }, context: Context) => {
const project = await context.dataSources.projects.findById(args.id);
if (!project) return null;
// 認可チェック
const membership = await context.dataSources.members.find(
args.id,
context.currentUser.id
);
if (!membership) throw new ForbiddenError('アクセス権限がありません');
return project;
},
},
Mutation: {
createTask: async (_parent: unknown, { input }: { input: CreateTaskInput }, context: Context) => {
try {
const task = await context.dataSources.tasks.create({
...input,
createdBy: context.currentUser.id,
});
return { task, errors: [] };
} catch (error) {
if (error instanceof ValidationError) {
return {
task: null,
errors: error.details.map(d => ({
field: d.field,
message: d.message,
})),
};
}
throw error;
}
},
},
// フィールドリゾルバー
Project: {
owner: async (project: Project, _args: unknown, context: Context) => {
return context.dataSources.users.findById(project.ownerId);
},
members: async (project: Project, _args: unknown, context: Context) => {
return context.dataSources.members.findByProject(project.id);
},
tasks: async (project: Project, args: { status?: string; limit?: number }, context: Context) => {
return context.dataSources.tasks.findByProject(project.id, {
status: args.status,
limit: args.limit,
});
},
taskCount: async (project: Project, _args: unknown, context: Context) => {
return context.dataSources.tasks.countByProject(project.id);
},
},
Task: {
assignee: async (task: Task, _args: unknown, context: Context) => {
if (!task.assigneeId) return null;
return context.dataSources.users.findById(task.assigneeId);
},
labels: async (task: Task, _args: unknown, context: Context) => {
return context.dataSources.labels.findByTask(task.id);
},
comments: async (task: Task, args: { limit?: number }, context: Context) => {
return context.dataSources.comments.findByTask(task.id, args.limit);
},
},
};
ページネーション(Connection パターン)
# Relay スタイルの Connection パターン
type ProjectConnection {
edges: [ProjectEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProjectEdge {
node: Project!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# 使用例
type Query {
projects(first: Int, after: String, last: Int, before: String): ProjectConnection!
}
# クエリの実行
query {
projects(first: 10, after: "cursor_abc") {
edges {
node {
id
name
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
まとめ
| ポイント | 内容 |
|---|---|
| スキーマ設計 | ドメインモデルを型として定義、Query/Mutationで操作を公開 |
| Input型 | 入力データ専用の型、デフォルト値の設定が可能 |
| Payload型 | Mutation のレスポンス、エラー情報を含める |
| リゾルバー | 各フィールドのデータ取得ロジック、contextで認証情報を受け渡し |
| Connection | Relay スタイルのカーソルベースページネーション |
チェックリスト
- GraphQLのスキーマ設計の原則を理解した
- Input型とPayload型の使い分けを把握した
- リゾルバーの基本的な実装パターンを理解した
- Connectionパターンによるページネーションを理解した
次のステップへ
スキーマ設計とリゾルバーを学びました。
次のセクションでは、GraphQLの最大の落とし穴 — N+1問題とDataLoaderを学びます。
推定読了時間: 40分