T-CREATOR

Apollo で GraphQL Schema 設計 - スケーラブルな API 構築術

Apollo で GraphQL Schema 設計 - スケーラブルな API 構築術

現代の Web アプリケーション開発において、データ取得の効率性と API の柔軟性は重要な要素です。Apollo GraphQL は、これらの課題を解決する強力なツールとして注目を集めています。

本記事では、Apollo GraphQL を使ったスケーラブルな Schema 設計の手法について、基礎から実践まで丁寧に解説いたします。GraphQL の基本概念から始まり、実際の開発現場で活用できる設計パターンまでをご紹介しますね。

背景

GraphQL と REST API の違い

従来の REST API では、複数のエンドポイントから必要なデータを取得する必要がありました。この仕組みは理解しやすい反面、いくつかの制約があります。

mermaidflowchart TD
    A[Client] -->|Request 1| B["/users endpoint"]
    A -->|Request 2| C["/posts endpoint"]
    A -->|Request 3| D["/comments endpoint"]
    B -->|User Data| A
    C -->|Posts Data| A
    D -->|Comments Data| A

REST API の課題:複数のリクエストが必要で、不要なデータも取得してしまいます。

一方、GraphQL では単一のエンドポイントで必要なデータのみを効率的に取得できます。

mermaidflowchart TD
    A[Client] -->|Single Query| B[GraphQL Endpoint]
    B -->|Exact Data| A
graphqlquery {
  user {
    name
    posts {
      title
      comments {
        content
      }
    }
  }
}

GraphQL の利点:1 回のリクエストで必要なデータを過不足なく取得できます。

項目REST APIGraphQL
エンドポイント数複数単一
データ取得量固定(Over/Under fetching)必要な分のみ
型安全性手動実装自動生成
リアルタイムWebSocket など別途実装Subscription 内蔵

Apollo が選ばれる理由

Apollo は GraphQL エコシステムにおいて最も成熟したフレームワークの一つです。その理由をご説明しましょう。

Apollo Client の特徴

  • 自動キャッシュ管理
  • 型安全なクエリ生成
  • リアルタイムデータ同期
  • エラーハンドリングの標準化

Apollo Server の特徴

  • Schema-first アプローチ
  • プラグインベースの拡張性
  • パフォーマンス監視機能
  • Federation 対応

これらの機能により、開発チームは効率的に GraphQL API を構築・運用できるようになります。

課題

スケーラビリティの問題

GraphQL API が成長するにつれて、以下のような課題が顕在化してきます。

N+1 問題 GraphQL では、リレーション先のデータを取得する際に N+1 問題が発生しやすくなります。

typescript// 問題のあるResolver実装例
const resolvers = {
  User: {
    posts: async (parent) => {
      // ユーザーごとに個別クエリが発行される(N+1問題)
      return await Post.findByUserId(parent.id);
    },
  },
};

この実装では、10 人のユーザーを取得する際に、1 回のユーザー取得クエリ + 10 回の投稿取得クエリが発生してしまいます。

Schema の複雑化 プロジェクトが大きくなるにつれて、Schema 定義が巨大化し、管理が困難になる問題があります。

Schema 設計における典型的な課題

型定義の重複 似たような型定義が複数箇所で定義されることで、保守性が低下します。

graphql# 重複の例:似た構造が複数定義されている
type User {
  id: ID!
  name: String!
  email: String!
}

type UserProfile {
  id: ID!
  name: String!
  email: String!
  bio: String
}

命名規則の不統一 チーム内で統一された命名規則がないことで、API の一貫性が失われてしまいます。

フィールドの責任範囲が曖昧 どのフィールドがどの Resolver で処理されるべきかが明確でない場合、予期しない副作用が発生する可能性があります。

解決策

Apollo Schema の基本設計原則

効果的な Schema 設計には、いくつかの重要な原則があります。それぞれ詳しく見ていきましょう。

1. ビジネスロジックベースの設計 技術的な制約ではなく、ビジネス要件を中心に Schema を設計します。

mermaidflowchart TD
    A[ビジネス要件] --> B[Schema設計]
    B --> C[型定義]
    B --> D[フィールド定義]
    B --> E[Resolver実装]

    subgraph "設計フロー"
        F["1. ドメインモデル分析"]
        G["2. エンティティ関係定義"]
        H["3. API仕様策定"]
        I["4. Schema実装"]
    end

ビジネス駆動で Schema を設計することで、自然で直感的な API が生まれます。

2. 単一責任の原則 各型やフィールドは明確な責任を持つように設計します。

graphql# 良い例:責任が明確に分かれている
type User {
  id: ID!
  profile: UserProfile!
  settings: UserSettings!
}

type UserProfile {
  name: String!
  email: String!
  avatar: String
}

type UserSettings {
  theme: Theme!
  notifications: NotificationSettings!
}

この設計により、それぞれの領域を独立して管理できるようになります。

型安全性を重視した設計手法

TypeScript と Apollo を組み合わせることで、強力な型安全性を実現できます。

GraphQL Code Generator の活用

typescript// 自動生成される型定義
export interface GetUserQueryVariables {
  id: string;
}

export interface GetUserQuery {
  user?: {
    id: string;
    name: string;
    posts: Array<{
      id: string;
      title: string;
    }>;
  } | null;
}

Resolver の型安全な実装

typescriptimport { Resolvers } from './generated/graphql';

const resolvers: Resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      return await dataSources.userAPI.getUser(id);
    },
  },
  User: {
    posts: async (parent, _, { dataSources }) => {
      return await dataSources.postAPI.getPostsByUserId(
        parent.id
      );
    },
  },
};

この実装により、コンパイル時に Resolver の型エラーを検出できるようになります。

モジュール化による保守性向上

大規模なプロジェクトでは、Schema を適切にモジュール化することが重要です。

Schema 定義の分割

typescript// user.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

extend type Query {
  user(id: ID!): User
  users: [User!]!
}
typescript// post.graphql
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}

extend type Query {
  post(id: ID!): Post
  posts: [Post!]!
}

Resolver の分割管理

typescript// resolvers/index.ts
import { userResolvers } from './user';
import { postResolvers } from './post';

export const resolvers = mergeResolvers([
  userResolvers,
  postResolvers,
]);
typescript// resolvers/user.ts
export const userResolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      return await dataSources.userAPI.getUser(id);
    },
  },
  User: {
    posts: async (parent, _, { dataSources }) => {
      return await dataSources.postAPI.getPostsByUserId(
        parent.id
      );
    },
  },
};

このモジュール化により、機能ごとに独立した開発・テストが可能になります。

具体例

基本的な Schema 定義

実際のプロジェクトでよく使われる Schema 定義のパターンをご紹介します。

ユーザー管理システムの例

graphql# ユーザー基本情報の定義
type User {
  id: ID!
  name: String!
  email: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# カスタムスカラー型の定義
scalar DateTime

# 検索結果の型定義
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

クエリとミューテーションの定義

graphqltype Query {
  # 単一ユーザーの取得
  user(id: ID!): User

  # ページネーション対応のユーザー一覧
  users(
    first: Int
    after: String
    last: Int
    before: String
    filter: UserFilter
  ): UserConnection!
}

type Mutation {
  # ユーザー作成
  createUser(input: CreateUserInput!): CreateUserPayload!

  # ユーザー更新
  updateUser(
    id: ID!
    input: UpdateUserInput!
  ): UpdateUserPayload!
}

# 入力型の定義
input CreateUserInput {
  name: String!
  email: String!
}

input UpdateUserInput {
  name: String
  email: String
}

input UserFilter {
  name: String
  email: String
}

# レスポンス型の定義
type CreateUserPayload {
  user: User
  errors: [Error!]
}

type UpdateUserPayload {
  user: User
  errors: [Error!]
}

type Error {
  field: String
  message: String!
}

この設計により、一貫性のある操作インターフェースを提供できます。

Resolver の実装

Schema に対応する Resolver の実装例をご紹介しましょう。

DataLoader を使った効率的な Resolver

typescriptimport DataLoader from 'dataloader';

// N+1問題を解決するDataLoaderの実装
const userLoader = new DataLoader(
  async (userIds: string[]) => {
    const users = await User.findByIds(userIds);
    return userIds.map((id) =>
      users.find((user) => user.id === id)
    );
  }
);

const postsByUserIdLoader = new DataLoader(
  async (userIds: string[]) => {
    const posts = await Post.findByUserIds(userIds);
    return userIds.map((userId) =>
      posts.filter((post) => post.userId === userId)
    );
  }
);

Context 経由で DataLoader を提供

typescript// Apollo Server設定
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    user: req.user,
    dataSources: {
      userAPI: new UserAPI(),
      postAPI: new PostAPI(),
    },
    loaders: {
      userLoader: userLoader,
      postsByUserIdLoader: postsByUserIdLoader,
    },
  }),
});

実際の Resolver 実装

typescriptconst resolvers = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return await loaders.userLoader.load(id);
    },

    users: async (_, args, { dataSources }) => {
      const { first, after, filter } = args;
      return await dataSources.userAPI.getUsers({
        limit: first,
        cursor: after,
        filter,
      });
    },
  },

  User: {
    posts: async (parent, _, { loaders }) => {
      return await loaders.postsByUserIdLoader.load(
        parent.id
      );
    },
  },

  Mutation: {
    createUser: async (_, { input }, { dataSources }) => {
      try {
        const user = await dataSources.userAPI.createUser(
          input
        );
        return { user, errors: [] };
      } catch (error) {
        return {
          user: null,
          errors: [
            { field: 'general', message: error.message },
          ],
        };
      }
    },
  },
};

この実装により、パフォーマンスとエラーハンドリングを両立できます。

Fragment 最適化

クライアント側でのクエリ最適化には、Fragment の活用が効果的です。

再利用可能な Fragment の定義

typescript// fragments/user.ts
export const USER_BASIC_INFO = gql`
  fragment UserBasicInfo on User {
    id
    name
    email
    createdAt
  }
`;

export const USER_WITH_POSTS = gql`
  fragment UserWithPosts on User {
    ...UserBasicInfo
    posts {
      id
      title
      createdAt
    }
  }
  ${USER_BASIC_INFO}
`;

Fragment を使ったクエリ

typescript// queries/getUser.ts
export const GET_USER_WITH_POSTS = gql`
  query GetUserWithPosts($id: ID!) {
    user(id: $id) {
      ...UserWithPosts
    }
  }
  ${USER_WITH_POSTS}
`;

React Component での使用例

typescriptimport { useQuery } from '@apollo/client';
import { GET_USER_WITH_POSTS } from '../queries/getUser';

const UserProfile = ({ userId }: { userId: string }) => {
  const { data, loading, error } = useQuery(
    GET_USER_WITH_POSTS,
    {
      variables: { id: userId },
    }
  );

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;

  return (
    <div>
      <h1>{data?.user?.name}</h1>
      <p>{data?.user?.email}</p>
      <ul>
        {data?.user?.posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

Fragment を使用することで、コードの重複を避け、一貫性のあるデータ取得が実現できます。

キャッシュポリシーの最適化

typescript// Apollo Client設定
const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      User: {
        fields: {
          posts: {
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
    },
  }),
});

この設定により、効率的なキャッシュ管理が可能になります。

まとめ

Apollo GraphQL を使った Schema 設計は、適切な原則と手法を理解することで、スケーラブルで保守性の高い API を構築できます。

本記事でご紹介した以下のポイントを意識して実装してみてください:

  • ビジネスロジックベースの設計:技術的制約ではなく、ビジネス要件を中心に Schema を設計する
  • 型安全性の重視:TypeScript と GraphQL Code Generator を活用した型安全な開発
  • モジュール化による管理:機能ごとに Schema 定義と Resolver を分割し、保守性を向上
  • DataLoader による最適化:N+1 問題を解決し、パフォーマンスを向上
  • Fragment の活用:クライアント側でのクエリ最適化とコード再利用

これらの手法を組み合わせることで、成長に耐えうる GraphQL API を構築できるでしょう。

初めて Apollo を使う方も、基本的な設計原則から段階的に学ぶことで、確実にスキルを身につけられます。実際のプロジェクトでぜひ活用してみてくださいね。

関連リンク