EXERCISE 90分

ストーリー

高橋アーキテクト
RESTの演習では TaskFlow の REST API を設計した。今度は同じ TaskFlow を GraphQL で設計してみよう

高橋アーキテクトが比較表を取り出した。

高橋アーキテクト
同じアプリケーションを異なるAPI方式で設計することで、それぞれのメリット・デメリットが肌で分かるようになる

ミッション概要

TaskFlow を GraphQL API として設計します。

ミッションテーマ難易度
Mission 1スキーマの型定義初級
Mission 2Query の設計中級
Mission 3Mutation の設計中級
Mission 4リゾルバーの実装上級
Mission 5DataLoader の実装上級
Mission 6REST との比較分析上級

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 2Query の設計[ ]
Mission 3Mutation の設計[ ]
Mission 4リゾルバーの実装[ ]
Mission 5DataLoader の実装[ ]
Mission 6REST との比較分析[ ]

まとめ

ポイント内容
スキーマ設計ドメインモデルを型として定義
Queryデータ取得、Connection パターンでページネーション
MutationInput型 + Payload型でエラーハンドリング
リゾルバー認可チェック、バリデーション、DataLoader活用

チェックリスト

  • GraphQLのスキーマを自分で設計できた
  • Query と Mutation を適切に定義できた
  • リゾルバーの実装パターンを理解した
  • RESTとGraphQLの使い分けを自分の言葉で説明できる

推定所要時間: 90分