T-CREATOR

Apollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化

Apollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化

GraphQL を使ったフロントエンド開発では、Apollo Client がデータ管理の中心的な役割を担っています。しかし、キャッシュの正規化が不十分だと、データの重複や不整合が発生してしまうことがあります。

この記事では、Apollo Client の keyFieldstypePolicies を活用した ID 設計の固定化について、初心者の方にもわかりやすく解説していきますね。適切な正規化設計を行うことで、パフォーマンスが向上し、データの一貫性も保てるようになるでしょう。

背景

Apollo Client のキャッシュ正規化とは

Apollo Client は、GraphQL クエリで取得したデータを自動的に正規化してキャッシュに保存します。正規化とは、データを一意の識別子(ID)で管理し、重複を排除する仕組みのことです。

たとえば、ユーザー情報を複数のクエリで取得した場合でも、同じユーザーであれば 1 つのキャッシュエントリとして管理されるんですね。これにより、メモリの節約とデータの一貫性が保たれます。

以下の図は、Apollo Client がどのようにデータを正規化してキャッシュに保存するかを示しています。

mermaidflowchart TB
  query["GraphQL クエリ"] -->|データ取得| apollo["Apollo Client"]
  apollo -->|正規化処理| normalize["正規化エンジン"]
  normalize -->|ID 生成| cache[("InMemoryCache")]
  cache -->|キャッシュキー| entry1["User:1"]
  cache -->|キャッシュキー| entry2["Post:abc"]
  cache -->|キャッシュキー| entry3["Comment:xyz"]

この図から、GraphQL クエリで取得したデータが正規化エンジンを経由し、一意の ID でキャッシュに保存される流れが理解できますね。

デフォルトの ID 生成ルール

Apollo Client は、デフォルトで以下のルールに基づいて ID を生成します。

  • オブジェクトに id フィールドがある場合:__typename:id の形式
  • オブジェクトに _id フィールドがある場合:__typename:_id の形式
  • どちらもない場合:正規化されずそのまま保存

この自動生成ルールは便利ですが、複雑なデータ構造では不十分な場合があるんです。

課題

ID 設計が不明確な場合の問題点

デフォルトの ID 生成ルールだけに頼ると、以下のような問題が発生する可能性があります。

まず、複合キーを持つオブジェクトの場合、単一の id フィールドだけでは一意性を保証できません。たとえば、ユーザーと投稿の組み合わせで一意になる「いいね」機能などが該当しますね。

次に、カスタムフィールドで ID を管理している場合、Apollo Client がそれを認識できず、データが重複してしまう可能性があります。

さらに、型によって ID の構成が異なる場合、手動で制御しないと正規化が正しく行われません。

以下の図は、ID 設計が不明確な場合に発生する問題を示しています。

mermaidflowchart LR
  query1["クエリ A"] -->|取得| user1["User データ"]
  query2["クエリ B"] -->|取得| user2["User データ"]
  user1 -->|ID 不明| cache1["キャッシュ A<br/>重複エントリ"]
  user2 -->|ID 不明| cache2["キャッシュ B<br/>重複エントリ"]
  cache1 -.->|本来は同一| cache2

同じユーザーのデータが重複してキャッシュされてしまうことで、メモリの無駄遣いとデータ不整合が発生してしまいますね。

実際に発生する問題例

実際の開発現場では、以下のような問題が起こることがあります。

データの不整合が発生するケースでは、同じユーザー情報を更新しても、一部のコンポーネントだけが更新されない事象が起きます。これは、同じデータが異なるキャッシュキーで保存されているためです。

パフォーマンスの低下も深刻な問題です。不必要にデータが重複すると、メモリ使用量が増加し、React コンポーネントの再レンダリングも増えてしまうでしょう。

デバッグが困難になるという問題もあります。Apollo Client DevTools でキャッシュを確認しても、どのデータがどのキーで保存されているのか把握しづらくなるんですね。

解決策

keyFields による ID 設計の固定化

keyFields を使うと、Apollo Client に対して「この型のオブジェクトは、これらのフィールドを使って ID を生成してください」と明示的に指示できます。

これにより、デフォルトのルールに依存せず、開発者が意図した ID 設計を実現できるんです。

以下のコードは、InMemoryCache の初期化時に typePolicies を設定する基本的な例です。

typescriptimport { InMemoryCache } from '@apollo/client';

// InMemoryCache の初期化
const cache = new InMemoryCache({
  typePolicies: {
    // User 型の ID 設計を定義
    User: {
      keyFields: ['id'], // id フィールドを使用
    },
  },
});

このコードでは、User 型のオブジェクトが id フィールドを使って正規化されるよう設定していますね。

typePolicies の基本構造

typePolicies は、型ごとにキャッシュの動作をカスタマイズするための設定オブジェクトです。主に以下の設定が可能です。

#設定項目説明
1keyFieldsID 生成に使用するフィールドを指定
2fieldsフィールドごとの読み取り・書き込みロジック
3mergeキャッシュ更新時のマージ処理

keyFields は最も重要な設定で、これを適切に設定することでデータの正規化が正しく行われるようになります。

複合キーの設定方法

単一のフィールドだけでなく、複数のフィールドを組み合わせて ID を生成することもできます。以下は、userIdpostId の組み合わせで一意になる「いいね」機能の例です。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Like 型は userId と postId の組み合わせで一意
    Like: {
      keyFields: ['userId', 'postId'],
    },
  },
});

この設定により、Apollo Client は Like:userId1:postId1 のような複合キーを生成してくれるんですね。

ネストされたフィールドの指定

オブジェクトがネストしている場合も、ドット記法を使って指定できます。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Product 型は category.id と sku で一意
    Product: {
      keyFields: ['category', ['id'], 'sku'],
    },
  },
});

この場合、Product オブジェクトは category.idsku の組み合わせで ID が生成されますよ。

以下の図は、typePolicieskeyFields がどのように連携してキャッシュキーを生成するかを示しています。

mermaidflowchart TB
  data["GraphQL レスポンス"] -->|型情報| policy["typePolicies"]
  policy -->|keyFields 参照| fields["指定フィールド抽出"]
  fields -->|値取得| generate["キャッシュキー生成"]
  generate -->|保存| cache[("InMemoryCache")]

  subgraph example["例: Like 型"]
    ex1["userId: 'user1'<br/>postId: 'post1'"] -->|結合| ex2["Like:user1:post1"]
  end

この仕組みにより、開発者が意図した ID 設計が確実に実現されるんですね。

具体例

基本的な実装例

実際のプロジェクトで Apollo Client を初期化する際の実装例を見ていきましょう。

まず、必要なパッケージをインポートします。

typescriptimport {
  ApolloClient,
  InMemoryCache,
  HttpLink,
} from '@apollo/client';

// GraphQL エンドポイントの設定
const httpLink = new HttpLink({
  uri: 'https://api.example.com/graphql',
});

次に、InMemoryCache を設定します。ここで typePolicies を定義していきますよ。

typescript// キャッシュの設定
const cache = new InMemoryCache({
  typePolicies: {
    // User 型の正規化ルール
    User: {
      keyFields: ['id'], // id フィールドで一意
    },

    // Post 型の正規化ルール
    Post: {
      keyFields: ['slug'], // slug フィールドで一意
    },
  },
});

この設定により、UseridPostslug で正規化されるようになりました。

最後に、Apollo Client のインスタンスを作成します。

typescript// Apollo Client のインスタンス作成
const client = new ApolloClient({
  link: httpLink,
  cache: cache,
});

export default client;

これで、アプリケーション全体で一貫した ID 設計が適用されるんですね。

複合キーの実装例

SNS アプリケーションで「いいね」機能を実装する場合、ユーザーと投稿の組み合わせで一意になる必要があります。

まず、GraphQL のスキーマ定義を確認しましょう。

graphql# いいね機能の型定義
type Like {
  userId: ID!
  postId: ID!
  createdAt: String!
}

# クエリ定義
type Query {
  likes(postId: ID!): [Like!]!
}

次に、Apollo Client 側で Like 型の正規化ルールを設定します。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Like 型は userId と postId の複合キー
    Like: {
      keyFields: ['userId', 'postId'],
    },
  },
});

この設定により、同じユーザーが同じ投稿に対して行った「いいね」は、常に 1 つのキャッシュエントリとして管理されますよ。

実際にクエリを実行してみましょう。

typescriptimport { useQuery, gql } from '@apollo/client';

// いいね一覧を取得するクエリ
const GET_LIKES = gql`
  query GetLikes($postId: ID!) {
    likes(postId: $postId) {
      userId
      postId
      createdAt
    }
  }
`;

// コンポーネントでの使用例
function LikesList({ postId }) {
  const { data, loading, error } = useQuery(GET_LIKES, {
    variables: { postId },
  });

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラーが発生しました</p>;

  return (
    <ul>
      {data.likes.map((like) => (
        // キャッシュキーは "Like:userId:postId" の形式で生成される
        <li key={`${like.userId}:${like.postId}`}>
          ユーザー {like.userId} が {like.createdAt}{' '}
          にいいねしました
        </li>
      ))}
    </ul>
  );
}

このコードでは、Like オブジェクトが userIdpostId の組み合わせでキャッシュされるため、データの重複が防げるんですね。

カスタムフィールドを使った実装例

既存のバックエンドで id_id 以外のフィールドを ID として使っている場合もあります。たとえば、商品マスタで productCode を主キーとしている場合です。

まず、GraphQL のスキーマを確認します。

graphql# 商品型の定義
type Product {
  productCode: String!
  name: String!
  price: Int!
  stock: Int!
}

Apollo Client 側で productCode を ID として使うよう設定しますよ。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Product 型は productCode で一意
    Product: {
      keyFields: ['productCode'],
    },
  },
});

これで、Product:ABC123 のようなキャッシュキーが生成されるようになりました。

実際にクエリを使って商品情報を取得してみましょう。

typescriptimport { useQuery, gql } from '@apollo/client';

// 商品情報取得クエリ
const GET_PRODUCT = gql`
  query GetProduct($productCode: String!) {
    product(productCode: $productCode) {
      productCode
      name
      price
      stock
    }
  }
`;

// コンポーネントでの使用
function ProductDetail({ productCode }) {
  const { data } = useQuery(GET_PRODUCT, {
    variables: { productCode },
  });

  // キャッシュキー: "Product:ABC123" の形式で管理される
  return data?.product ? (
    <div>
      <h2>{data.product.name}</h2>
      <p>価格: ¥{data.product.price.toLocaleString()}</p>
      <p>在庫: {data.product.stock} 個</p>
    </div>
  ) : null;
}

カスタムフィールドを ID として使うことで、既存のデータベース設計に合わせた柔軟な実装が可能になるんですね。

ネストされたオブジェクトの実装例

EC サイトでは、商品がカテゴリーに属しており、カテゴリー ID と商品コードの組み合わせで一意になることがあります。

GraphQL スキーマは以下のようになっているとしましょう。

graphql# カテゴリー型
type Category {
  id: ID!
  name: String!
}

# 商品型(カテゴリー情報を含む)
type Product {
  category: Category!
  sku: String!
  name: String!
  price: Int!
}

Apollo Client でネストされたフィールドを指定します。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Product 型は category.id と sku の組み合わせで一意
    Product: {
      keyFields: ['category', ['id'], 'sku'],
    },

    // Category 型は id で一意
    Category: {
      keyFields: ['id'],
    },
  },
});

この設定により、Productcategory.idsku を組み合わせた ID で正規化されますよ。

クエリを実行して動作を確認しましょう。

typescriptimport { useQuery, gql } from '@apollo/client';

// 商品一覧取得クエリ
const GET_PRODUCTS = gql`
  query GetProducts {
    products {
      category {
        id
        name
      }
      sku
      name
      price
    }
  }
`;

// コンポーネント例
function ProductList() {
  const { data } = useQuery(GET_PRODUCTS);

  return (
    <div>
      {data?.products.map((product) => (
        // キャッシュキーは "Product:categoryId:sku" の形式
        <div key={`${product.category.id}:${product.sku}`}>
          <span>{product.category.name}</span>
          <h3>{product.name}</h3>
          <p>SKU: {product.sku}</p>
          <p>¥{product.price.toLocaleString()}</p>
        </div>
      ))}
    </div>
  );
}

ネストされたフィールドを ID に含めることで、複雑なデータ構造でも正確に正規化できるんですね。

keyFields: false による正規化の無効化

特定の型については、正規化せずにそのまま保存したい場合もあります。たとえば、設定情報や一時的なデータなどです。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // Settings 型は正規化しない
    Settings: {
      keyFields: false,
    },
  },
});

keyFields: false を指定すると、その型のオブジェクトは親オブジェクトの一部として直接保存されますよ。

以下の図は、正規化あり・なしでのキャッシュ構造の違いを示しています。

mermaidflowchart LR
  subgraph normalized["正規化あり"]
    user1["User:1"] -->|参照| posts1["Post:abc"]
  end

  subgraph notNormalized["正規化なし (keyFields: false)"]
    user2["User:2"] -->|埋め込み| settings["settings: {...}"]
  end

正規化を無効化することで、シンプルなデータ構造では管理がしやすくなる場合があるんです。

動的な keyFields の実装

関数を使って、動的に ID を生成することも可能です。複雑な条件に基づいて ID を決定したい場合に便利ですよ。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    // 動的に ID を生成する例
    Article: {
      keyFields: (object, context) => {
        // draft が true の場合は draftId を使用
        if (object.draft) {
          return ['draftId'];
        }
        // それ以外は id を使用
        return ['id'];
      },
    },
  },
});

この設定では、記事の下書き状態に応じて異なるフィールドを ID として使用していますね。

関数の引数を活用して、より柔軟な制御も可能です。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Comment: {
      keyFields: (
        object,
        { typename, selectionSet, fragmentMap }
      ) => {
        // オブジェクトの内容に基づいて条件分岐
        if (object.tempId) {
          // 一時的な ID を使用
          return ['tempId'];
        }
        if (object.id && object.version) {
          // バージョン管理が必要な場合は複合キー
          return ['id', 'version'];
        }
        // デフォルトは id のみ
        return ['id'];
      },
    },
  },
});

動的な keyFields を使うことで、アプリケーションの状態や要件に応じた柔軟な ID 設計が実現できるんですね。

まとめ

Apollo Client の keyFieldstypePolicies を活用することで、GraphQL アプリケーションのキャッシュ正規化を完全に制御できるようになります。

これまで見てきたように、適切な ID 設計を行うことで以下のメリットが得られますよ。

データの一貫性が保たれることで、同じデータが複数のキャッシュエントリとして重複することがなくなります。UI の更新も確実に反映されるんですね。

パフォーマンスが向上するという点も重要です。不要なデータの重複が減り、メモリ使用量が最適化されます。React コンポーネントの再レンダリングも最小限に抑えられるでしょう。

デバッグが容易になるのも大きな利点です。Apollo Client DevTools でキャッシュの状態を確認する際、どのデータがどのキーで保存されているか明確にわかるため、問題の特定がスムーズになりますね。

開発チームでの認識統一も図れます。typePolicies を明示的に定義することで、ID 設計がコードとして文書化され、チームメンバー全員が同じ理解を持てるんです。

実装時のポイントとしては、以下を意識すると良いでしょう。

まず、プロジェクト初期段階で全ての型について keyFields を定義しておくことをお勧めします。後から追加すると、既存のキャッシュとの整合性に問題が生じる可能性があるためです。

次に、バックエンドの ID 設計と一致させることが重要ですよ。GraphQL スキーマとデータベースの主キーが一致していれば、より自然な実装になります。

テストも忘れずに実施しましょう。Apollo Client の cache.extract() メソッドを使って、期待通りのキャッシュキーが生成されているか確認できますね。

Apollo Client の正規化設計は、アプリケーションの品質を左右する重要な要素です。keyFieldstypePolicies を適切に設定して、堅牢で保守性の高いフロントエンドを実現してくださいね。

関連リンク