ストーリー
高橋アーキテクトが比較表を取り出した。
ミッション概要
TaskFlow を GraphQL API として設計します。
| ミッション | テーマ | 難易度 |
|---|---|---|
| Mission 1 | スキーマの型定義 | 初級 |
| Mission 2 | Query の設計 | 中級 |
| Mission 3 | Mutation の設計 | 中級 |
| Mission 4 | リゾルバーの実装 | 上級 |
| Mission 5 | DataLoader の実装 | 上級 |
| Mission 6 | REST との比較分析 | 上級 |
Mission 1: スキーマの型定義(15分)
TaskFlow のオブジェクト型、列挙型、スカラー型を定義してください。
要件
- User, Project, Task, Comment, Label の型を定義
- TaskStatus, Priority, MemberRole の列挙型を定義
- DateTime カスタムスカラー型を定義
解答
scalar DateTime
enum TaskStatus {
TODO
IN_PROGRESS
IN_REVIEW
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
CRITICAL
}
enum MemberRole {
OWNER
ADMIN
MEMBER
VIEWER
}
type User {
id: ID!
name: String!
email: String!
avatarUrl: String
createdAt: DateTime!
}
type Project {
id: ID!
name: String!
description: String
owner: User!
members: [ProjectMember!]!
tasks(status: TaskStatus, priority: Priority, limit: Int): [Task!]!
taskCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type ProjectMember {
user: User!
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!
createdAt: DateTime!
updatedAt: DateTime!
}
type Label {
id: ID!
name: String!
color: String!
}
Mission 2: Query の設計(15分)
データ取得のための Query ルート型を設計してください。
要件
- 自分の情報取得(me)
- プロジェクト一覧(ページネーション付き)
- プロジェクト詳細
- タスク詳細
- タスク検索
解答
type Query {
# 認証ユーザー情報
me: User!
# プロジェクト
projects(first: Int = 20, after: String): ProjectConnection!
project(id: ID!): Project
# タスク
task(id: ID!): Task
searchTasks(
query: String!
projectId: ID
status: TaskStatus
priority: Priority
first: Int = 20
after: String
): TaskConnection!
}
# Connection パターン(Relay スタイル)
type ProjectConnection {
edges: [ProjectEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProjectEdge {
node: Project!
cursor: String!
}
type TaskConnection {
edges: [TaskEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type TaskEdge {
node: Task!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Mission 3: Mutation の設計(20分)
データ変更のための Mutation ルート型を設計してください。
要件
- タスクの作成、更新、削除
- コメントの追加
- プロジェクトメンバーの招待
- 各 Mutation に Input 型と Payload 型を定義
解答
type Mutation {
# タスク
createTask(input: CreateTaskInput!): CreateTaskPayload!
updateTask(id: ID!, input: UpdateTaskInput!): UpdateTaskPayload!
deleteTask(id: ID!): DeleteTaskPayload!
updateTaskStatus(id: ID!, status: TaskStatus!): UpdateTaskPayload!
# コメント
addComment(input: AddCommentInput!): AddCommentPayload!
deleteComment(id: ID!): DeleteCommentPayload!
# メンバー
inviteMember(input: InviteMemberInput!): InviteMemberPayload!
removeMember(projectId: ID!, userId: ID!): RemoveMemberPayload!
}
# === 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
}
input AddCommentInput {
taskId: ID!
body: String!
}
input InviteMemberInput {
projectId: ID!
userId: ID!
role: MemberRole!
}
# === Payload 型 ===
type CreateTaskPayload {
task: Task
errors: [UserError!]!
}
type UpdateTaskPayload {
task: Task
errors: [UserError!]!
}
type DeleteTaskPayload {
success: Boolean!
errors: [UserError!]!
}
type AddCommentPayload {
comment: Comment
errors: [UserError!]!
}
type DeleteCommentPayload {
success: Boolean!
errors: [UserError!]!
}
type InviteMemberPayload {
member: ProjectMember
errors: [UserError!]!
}
type RemoveMemberPayload {
success: Boolean!
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: String!
}
Mission 4: リゾルバーの実装(20分)
主要なリゾルバーをTypeScriptで実装してください。
要件
- Query.project リゾルバー(認可チェック付き)
- Mutation.createTask リゾルバー(バリデーション付き)
- Task.assignee フィールドリゾルバー
解答
interface Context {
currentUser: User;
loaders: ReturnType<typeof createLoaders>;
dataSources: DataSources;
}
const resolvers = {
Query: {
project: async (_: unknown, { id }: { id: string }, ctx: Context) => {
const project = await ctx.dataSources.projects.findById(id);
if (!project) return null;
// 認可チェック: プロジェクトメンバーのみアクセス可能
const membership = await ctx.dataSources.members.findByProjectAndUser(
id,
ctx.currentUser.id
);
if (!membership) {
throw new GraphQLError('このプロジェクトへのアクセス権限がありません', {
extensions: { code: 'FORBIDDEN' },
});
}
return project;
},
me: async (_: unknown, __: unknown, ctx: Context) => {
return ctx.currentUser;
},
},
Mutation: {
createTask: async (
_: unknown,
{ input }: { input: CreateTaskInput },
ctx: Context
) => {
// バリデーション
const errors: UserError[] = [];
if (!input.title || input.title.trim().length === 0) {
errors.push({
field: 'title',
message: 'タイトルは必須です',
code: 'REQUIRED',
});
}
if (input.title && input.title.length > 200) {
errors.push({
field: 'title',
message: 'タイトルは200文字以内にしてください',
code: 'MAX_LENGTH',
});
}
// 認可チェック
const membership = await ctx.dataSources.members.findByProjectAndUser(
input.projectId,
ctx.currentUser.id
);
if (!membership || membership.role === 'VIEWER') {
errors.push({
field: null,
message: 'タスクを作成する権限がありません',
code: 'FORBIDDEN',
});
}
if (errors.length > 0) {
return { task: null, errors };
}
// タスク作成
const task = await ctx.dataSources.tasks.create({
...input,
status: 'TODO',
createdBy: ctx.currentUser.id,
});
return { task, errors: [] };
},
},
Task: {
assignee: async (task: Task, _: unknown, ctx: Context) => {
if (!task.assigneeId) return null;
return ctx.loaders.user.load(task.assigneeId);
},
labels: async (task: Task, _: unknown, ctx: Context) => {
return ctx.loaders.labelsByTask.load(task.id);
},
project: async (task: Task, _: unknown, ctx: Context) => {
return ctx.loaders.project.load(task.projectId);
},
commentCount: async (task: Task, _: unknown, ctx: Context) => {
return ctx.dataSources.comments.countByTask(task.id);
},
},
};
Mission 5: DataLoader の実装(10分)
N+1問題を解決するDataLoaderを実装してください。
解答
import DataLoader from 'dataloader';
function createLoaders() {
return {
user: new DataLoader<string, User>(async (ids) => {
const users = await userRepository.findByIds([...ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) || new Error(`User not found: ${id}`));
}),
project: new DataLoader<string, Project>(async (ids) => {
const projects = await projectRepository.findByIds([...ids]);
const map = new Map(projects.map(p => [p.id, p]));
return ids.map(id => map.get(id) || new Error(`Project not found: ${id}`));
}),
labelsByTask: new DataLoader<string, Label[]>(async (taskIds) => {
const allLabels = await labelRepository.findByTaskIds([...taskIds]);
const grouped = new Map<string, Label[]>();
for (const { taskId, label } of allLabels) {
const labels = grouped.get(taskId) || [];
labels.push(label);
grouped.set(taskId, labels);
}
return taskIds.map(id => grouped.get(id) || []);
}),
commentCountByTask: new DataLoader<string, number>(async (taskIds) => {
const counts = await commentRepository.countByTaskIds([...taskIds]);
const map = new Map(counts.map(c => [c.taskId, c.count]));
return taskIds.map(id => map.get(id) || 0);
}),
};
}
Mission 6: REST との比較分析(10分)
同じ TaskFlow アプリケーションについて、REST APIとGraphQL APIの違いを分析してください。
解答
ダッシュボード画面の表示に必要なデータ取得:
■ REST API の場合:
リクエスト数: 4回
1. GET /api/v1/users/me → ユーザー情報
2. GET /api/v1/projects?limit=3 → プロジェクト一覧
3. GET /api/v1/tasks?assignee=me&limit=5 → 自分のタスク
4. GET /api/v1/notifications?unread=true → 通知
帯域: 多い(各レスポンスに不要なフィールドも含まれる)
キャッシュ: HTTPキャッシュが効く
■ GraphQL API の場合:
リクエスト数: 1回
query Dashboard {
me { name, avatarUrl }
projects(first: 3) { edges { node { id, name, taskCount } } }
myTasks(limit: 5) { id, title, status, assignee { name } }
notifications(unread: true) { id, message, createdAt }
}
帯域: 少ない(必要なフィールドのみ)
キャッシュ: クライアント側キャッシュが必要
■ 結論:
- ダッシュボードのような複雑な画面: GraphQL が有利
- シンプルなCRUD操作: REST で十分
- 公開API: REST が標準的
- モバイルアプリ: GraphQL が帯域節約に有利
達成度チェック
| ミッション | テーマ | 完了 |
|---|---|---|
| Mission 1 | スキーマの型定義 | [ ] |
| Mission 2 | Query の設計 | [ ] |
| Mission 3 | Mutation の設計 | [ ] |
| Mission 4 | リゾルバーの実装 | [ ] |
| Mission 5 | DataLoader の実装 | [ ] |
| Mission 6 | REST との比較分析 | [ ] |
まとめ
| ポイント | 内容 |
|---|---|
| スキーマ設計 | ドメインモデルを型として定義 |
| Query | データ取得、Connection パターンでページネーション |
| Mutation | Input型 + Payload型でエラーハンドリング |
| リゾルバー | 認可チェック、バリデーション、DataLoader活用 |
チェックリスト
- GraphQLのスキーマを自分で設計できた
- Query と Mutation を適切に定義できた
- リゾルバーの実装パターンを理解した
- RESTとGraphQLの使い分けを自分の言葉で説明できる
推定所要時間: 90分