LESSON 40分

ストーリー

高橋アーキテクト
GraphQLの基本は分かった。次はスキーマ設計の深掘りだ

高橋アーキテクトが複雑なスキーマ図を広げた。

高橋アーキテクト
RESTでリソース設計が重要だったように、GraphQLではスキーマ設計がすべてだ。スキーマがAPIの契約そのものになる
あなた
リゾルバーというのが、実際のデータ取得ロジックですか?
高橋アーキテクト
その通り。スキーマが”何を取得できるか”を定義し、リゾルバーが”どうやって取得するか”を実装する。この分離が大切だ

スキーマ設計の原則

ドメインモデルの反映

# 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で認証情報を受け渡し
ConnectionRelay スタイルのカーソルベースページネーション

チェックリスト

  • GraphQLのスキーマ設計の原則を理解した
  • Input型とPayload型の使い分けを把握した
  • リゾルバーの基本的な実装パターンを理解した
  • Connectionパターンによるページネーションを理解した

次のステップへ

スキーマ設計とリゾルバーを学びました。

次のセクションでは、GraphQLの最大の落とし穴 — N+1問題とDataLoaderを学びます。


推定読了時間: 40分