T-CREATOR

Apollo で BFF(Backend for Frontend)最適化:画面別スキーマと Contract Graph の併用

Apollo で BFF(Backend for Frontend)最適化:画面別スキーマと Contract Graph の併用

近年のフロントエンド開発において、複数のマイクロサービスからデータを取得し、画面に最適な形で提供することは大きな課題となっています。Apollo GraphQL の BFF パターンを活用すれば、バックエンドの複雑さをフロントエンドから隠蔽し、画面ごとに最適化されたデータ取得を実現できます。

本記事では、Apollo GraphQL で BFF を構築する際に、画面別スキーマ設計と Contract Graph を併用することで、開発効率とパフォーマンスを両立させる方法を解説します。

背景

BFF パターンとは

BFF(Backend for Frontend)は、フロントエンドのために特化したバックエンド層を設けるアーキテクチャパターンです。従来のモノリシックな API や、複数のマイクロサービスに直接アクセスする方式と比較して、フロントエンドの要求に最適化されたデータ提供が可能になります。

Apollo GraphQL における BFF の位置づけ

Apollo GraphQL は、GraphQL のクライアントとサーバー実装を提供する強力なフレームワークです。BFF 層として Apollo Server を配置することで、以下のメリットが得られます。

  • 複数のバックエンドサービスを統合し、単一の GraphQL エンドポイントを提供
  • フロントエンドが必要とするデータ構造に最適化
  • キャッシュ戦略の一元管理
  • 型安全性の向上

以下の図は、Apollo を用いた BFF アーキテクチャの全体像を示しています。

mermaidflowchart TB
  web["Web アプリ"] -->|GraphQL クエリ| bff["Apollo BFF<br/>GraphQL Gateway"]
  mobile["モバイルアプリ"] -->|GraphQL クエリ| bff

  bff -->|REST/GraphQL| userService["ユーザー<br/>サービス"]
  bff -->|REST/GraphQL| productService["商品<br/>サービス"]
  bff -->|REST/GraphQL| orderService["注文<br/>サービス"]

  userService -->|データ| db1[("DB")]
  productService -->|データ| db2[("DB")]
  orderService -->|データ| db3[("DB")]

このように、BFF 層が各マイクロサービスとクライアント間の橋渡しを行い、クライアントは複雑なバックエンド構成を意識せずにデータを取得できます。

画面別最適化の必要性

モダンな Web アプリケーションでは、画面ごとに必要なデータの種類や量が大きく異なります。例えば、商品一覧画面では商品の基本情報のみで十分ですが、商品詳細画面では在庫情報、レビュー、関連商品など豊富なデータが必要です。

すべての画面で同じスキーマを使うと、以下の問題が発生します。

  • 不要なデータまで取得してしまい、パフォーマンスが低下
  • 複雑なクエリの記述が必要となり、フロントエンド開発の負担増加
  • キャッシュ戦略の最適化が困難

課題

従来の単一スキーマアプローチの限界

多くの Apollo GraphQL 実装では、全画面で共通の単一スキーマを使用しています。この方式には以下の課題があります。

パフォーマンスの問題

単一スキーマでは、各画面が必要とするデータを細かく指定する必要があり、クエリが複雑化します。また、過剰なデータ取得(Over-fetching)や、複数回のリクエストによる往復遅延(N+1 問題)が発生しやすくなります。

開発効率の低下

フロントエンド開発者は、バックエンドの詳細な構造を理解し、複雑な GraphQL クエリを記述しなければなりません。これにより、開発速度が低下し、バグの混入リスクも高まります。

スキーマ変更の影響範囲

単一スキーマを変更すると、すべてのクライアントに影響を与える可能性があります。特定の画面のための最適化が、他の画面に予期せぬ影響を及ぼすことがあるでしょう。

以下の図は、従来の単一スキーマアプローチにおける課題を示しています。

mermaidflowchart TD
  schema["単一の GraphQL スキーマ"]

  schema -->|複雑なクエリ| list["一覧画面<br/>(基本情報のみ必要)"]
  schema -->|複雑なクエリ| detail["詳細画面<br/>(詳細情報必要)"]
  schema -->|複雑なクエリ| cart["カート画面<br/>(在庫・価格情報必要)"]

  list -.->|Over-fetching| issue1["不要データ取得"]
  detail -.->|N+1 問題| issue2["複数回通信"]
  cart -.->|複雑性| issue3["開発負担増加"]

Contract Graph の必要性

マイクロサービス環境では、複数のチームが独立してサービスを開発します。各サービスのスキーマ変更が他のサービスやクライアントに影響を与えないようにするため、契約(Contract)ベースの開発が重要になります。

しかし、従来の方式では以下の問題がありました。

  • スキーマ変更時の互換性チェックが手動で煩雑
  • 破壊的変更の検出が困難
  • バージョン管理とロールバックの仕組みが不十分

解決策

画面別スキーマ設計の導入

画面別スキーマ設計では、各画面(またはユースケース)ごとに最適化された GraphQL スキーマを定義します。これにより、以下のメリットが得られます。

  • 各画面が必要とするデータのみを明確に定義
  • クエリの簡素化とパフォーマンスの向上
  • 画面単位での独立した開発とテストが可能

画面別スキーマの基本構造

画面別スキーマでは、画面の目的に応じて専用の Type と Query を定義します。以下は、商品一覧画面と商品詳細画面の例です。

商品一覧画面用スキーマ

typescript// schemas/productList.graphql.ts
import { gql } from 'apollo-server';

export const productListSchema = gql`
  """
  商品一覧画面用の商品型
  表示に必要な最小限の情報のみを含む
  """
  type ProductListItem {
    id: ID!
    name: String!
    price: Int!
    thumbnailUrl: String!
    inStock: Boolean!
  }

  """
  商品一覧のページネーション結果
  """
  type ProductListResult {
    items: [ProductListItem!]!
    totalCount: Int!
    hasNextPage: Boolean!
  }

  type Query {
    """
    商品一覧を取得
    """
    productList(
      page: Int = 1
      limit: Int = 20
      category: String
    ): ProductListResult!
  }
`;

このスキーマでは、一覧表示に必要な最小限のフィールド(ID、名前、価格、サムネイル、在庫状況)のみを定義しています。

商品詳細画面用スキーマ

typescript// schemas/productDetail.graphql.ts
import { gql } from 'apollo-server';

export const productDetailSchema = gql`
  """
  商品詳細画面用の商品型
  詳細情報を含む完全な商品データ
  """
  type ProductDetail {
    id: ID!
    name: String!
    price: Int!
    description: String!
    images: [String!]!
    inStock: Boolean!
    stockQuantity: Int!
    specifications: [Specification!]!
    reviews: ReviewConnection!
    relatedProducts: [ProductListItem!]!
  }

  """
  商品仕様情報
  """
  type Specification {
    key: String!
    value: String!
  }

  """
  レビュー情報のページネーション
  """
  type ReviewConnection {
    items: [Review!]!
    averageRating: Float!
    totalCount: Int!
  }

  type Review {
    id: ID!
    rating: Int!
    comment: String!
    userName: String!
    createdAt: String!
  }

  type Query {
    """
    商品詳細を取得
    """
    productDetail(id: ID!): ProductDetail!
  }
`;

詳細画面用スキーマでは、説明文、複数画像、仕様、レビュー、関連商品など、詳細な情報を含めています。

スキーマの統合

複数の画面別スキーマを統合して、単一の Apollo Server で提供します。

typescript// server.ts
import { ApolloServer } from 'apollo-server';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { productListSchema } from './schemas/productList.graphql';
import { productDetailSchema } from './schemas/productDetail.graphql';
import { resolvers } from './resolvers';

// スキーマを統合
const schema = makeExecutableSchema({
  typeDefs: [
    productListSchema,
    productDetailSchema,
    // 他の画面用スキーマも追加
  ],
  resolvers,
});

この方式により、各画面が最適化されたクエリを使用しつつ、単一のエンドポイントでサービスを提供できます。

Contract Graph による変更管理

Contract Graph は、Apollo Studio が提供する機能で、スキーマの変更を安全に管理できます。主な特徴は以下の通りです。

スキーマバージョニング

各スキーマのバージョンを管理し、破壊的変更を検出します。

typescript// apollo.config.js
module.exports = {
  client: {
    service: {
      name: 'product-bff',
      url: 'http://localhost:4000/graphql',
    },
  },
  engine: {
    apiKey: process.env.APOLLO_KEY,
    // Contract Graph の設定
    schemaTag: 'current',
  },
};

スキーマチェックの自動化

CI/CD パイプラインにスキーマチェックを組み込みます。

yaml# .github/workflows/schema-check.yml
name: Schema Check

on:
  pull_request:
    branches: [main]

jobs:
  schema-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

Node.js のセットアップ後、依存関係をインストールします。

yaml- name: Install dependencies
  run: yarn install

- name: Run Apollo Schema Check
  env:
    APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
  run: |
    yarn apollo schema:check \
      --endpoint=http://localhost:4000/graphql

このワークフローにより、Pull Request 作成時に自動的にスキーマの互換性がチェックされ、破壊的変更がある場合は警告が表示されます。

Contract の定義

Contract Graph では、異なるクライアント(Web、モバイルなど)ごとに契約を定義できます。

typescript// contracts/web.contract.ts
export const webContract = {
  name: 'web-client',
  // Web クライアントが使用するスキーマの範囲を定義
  includedTypes: [
    'ProductListItem',
    'ProductListResult',
    'ProductDetail',
    'Query.productList',
    'Query.productDetail',
  ],
  // 除外するフィールド(内部管理用など)
  excludedFields: [
    'ProductDetail.internalNotes',
    'ProductDetail.supplierInfo',
  ],
};

これにより、クライアントごとに異なるスキーマのサブセットを提供し、不要な情報の露出を防げます。

画面別スキーマと Contract Graph の併用

画面別スキーマと Contract Graph を併用することで、最適化と安全性を両立できます。

以下の図は、併用時のアーキテクチャを示しています。

mermaidflowchart TB
  subgraph clients["クライアント層"]
    web["Web アプリ"]
    mobile["モバイルアプリ"]
  end

  subgraph bff["BFF 層(Apollo Server)"]
    listSchema["一覧画面<br/>スキーマ"]
    detailSchema["詳細画面<br/>スキーマ"]
    cartSchema["カート画面<br/>スキーマ"]
  end

  subgraph contract["Contract Graph"]
    webContract["Web Contract"]
    mobileContract["Mobile Contract"]
  end

  subgraph services["マイクロサービス層"]
    userSvc["ユーザー<br/>サービス"]
    productSvc["商品<br/>サービス"]
    orderSvc["注文<br/>サービス"]
  end

  web -->|使用| webContract
  mobile -->|使用| mobileContract

  webContract -.->|検証| listSchema
  webContract -.->|検証| detailSchema
  mobileContract -.->|検証| cartSchema

  listSchema -->|データ取得| productSvc
  detailSchema -->|データ取得| productSvc
  detailSchema -->|データ取得| userSvc
  cartSchema -->|データ取得| orderSvc
  cartSchema -->|データ取得| productSvc

この構成により、各画面は最適化されたスキーマを使用し、Contract Graph が変更の安全性を保証します。

具体例

実装例:商品検索システム

実際の商品検索システムを例に、画面別スキーマと Contract Graph の実装を見ていきましょう。

プロジェクト構成

bashproduct-bff/
├── src/
│   ├── schemas/           # 画面別スキーマ定義
│   │   ├── productList.ts
│   │   ├── productDetail.ts
│   │   └── cart.ts
│   ├── resolvers/         # リゾルバー実装
│   │   ├── productList.resolvers.ts
│   │   ├── productDetail.resolvers.ts
│   │   └── cart.resolvers.ts
│   ├── datasources/       # データソース
│   │   ├── ProductAPI.ts
│   │   ├── UserAPI.ts
│   │   └── OrderAPI.ts
│   ├── contracts/         # Contract 定義
│   │   ├── web.contract.ts
│   │   └── mobile.contract.ts
│   └── server.ts          # サーバーエントリーポイント
├── apollo.config.js       # Apollo 設定
└── package.json

この構成により、画面ごとにスキーマとリゾルバーを分離し、保守性を高めています。

データソースの実装

まず、バックエンドサービスと通信するデータソースを実装します。

typescript// src/datasources/ProductAPI.ts
import { RESTDataSource } from '@apollo/datasource-rest';

/**
 * 商品サービスとの通信を担当するデータソース
 * RESTDataSource を継承し、キャッシュ機能を活用
 */
export class ProductAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/products/';

  /**
   * 商品一覧を取得
   * ページネーションとフィルタリングをサポート
   */
  async getProductList(params: {
    page: number;
    limit: number;
    category?: string;
  }) {
    return this.get('list', {
      params: {
        page: params.page.toString(),
        limit: params.limit.toString(),
        ...(params.category && {
          category: params.category,
        }),
      },
    });
  }

  /**
   * 商品詳細を取得
   * ID を指定して単一の商品情報を取得
   */
  async getProductDetail(id: string) {
    return this.get(`detail/${id}`);
  }
}

RESTDataSource を使用することで、自動的にリクエストのキャッシュやバッチ処理が行われ、パフォーマンスが向上します。

商品一覧画面のリゾルバー実装

typescript// src/resolvers/productList.resolvers.ts
import { ProductAPI } from '../datasources/ProductAPI';

interface Context {
  dataSources: {
    productAPI: ProductAPI;
  };
}

interface ProductListArgs {
  page?: number;
  limit?: number;
  category?: string;
}

/**
 * 商品一覧画面用のリゾルバー
 * 一覧表示に最適化されたデータ取得を実現
 */
export const productListResolvers = {
  Query: {
    productList: async (
      _: any,
      args: ProductListArgs,
      { dataSources }: Context
    ) => {
      // デフォルト値の設定
      const page = args.page || 1;
      const limit = args.limit || 20;

      // データソースから商品一覧を取得
      const result =
        await dataSources.productAPI.getProductList({
          page,
          limit,
          category: args.category,
        });

      return {
        items: result.items,
        totalCount: result.total,
        hasNextPage: result.total > page * limit,
      };
    },
  },
};

このリゾルバーは、一覧表示に必要な情報のみを取得し、ページネーション情報も含めて返します。

商品詳細画面のリゾルバー実装

詳細画面では、複数のデータソースから情報を集約します。

typescript// src/resolvers/productDetail.resolvers.ts
import { ProductAPI } from '../datasources/ProductAPI';
import { UserAPI } from '../datasources/UserAPI';

interface Context {
  dataSources: {
    productAPI: ProductAPI;
    userAPI: UserAPI;
  };
}

/**
 * 商品詳細画面用のリゾルバー
 * 商品情報、レビュー、関連商品を統合して提供
 */
export const productDetailResolvers = {
  Query: {
    productDetail: async (
      _: any,
      { id }: { id: string },
      { dataSources }: Context
    ) => {
      // 商品基本情報を取得
      const product = await dataSources.productAPI.getProductDetail(id);

      return {
        ...product,
        // 他のフィールドは個別のリゾルバーで解決
      };
    },
  },

  ProductDetail: {
    /**
     * レビュー情報の取得
     * N+1 問題を避けるため、DataLoader の使用を推奨
     */
    reviews: async (
      parent: any,
      _: any,
      { dataSources }: Context
    ) => {
      const reviews = await dataSources.productAPI.getReviews(parent.id);

      return {
        items: reviews.items,
        averageRating: reviews.average,
        totalCount: reviews.total,
      };
    },

関連商品の取得を実装します。

typescript    /**
     * 関連商品の取得
     * 商品詳細ページに表示する推奨商品
     */
    relatedProducts: async (
      parent: any,
      _: any,
      { dataSources }: Context
    ) => {
      const related = await dataSources.productAPI.getRelatedProducts(
        parent.id
      );

      // ProductListItem 形式に変換
      return related.map((item: any) => ({
        id: item.id,
        name: item.name,
        price: item.price,
        thumbnailUrl: item.thumbnail,
        inStock: item.stock > 0,
      }));
    },
  },
};

このように、フィールドごとに個別のリゾルバーを定義することで、必要なデータのみを効率的に取得できます。

サーバーの起動とスキーマ統合

typescript// src/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { ProductAPI } from './datasources/ProductAPI';
import { UserAPI } from './datasources/UserAPI';
import { productListSchema } from './schemas/productList';
import { productDetailSchema } from './schemas/productDetail';
import { productListResolvers } from './resolvers/productList.resolvers';
import { productDetailResolvers } from './resolvers/productDetail.resolvers';

/**
 * 実行可能なスキーマを作成
 * 複数の画面別スキーマとリゾルバーを統合
 */
const schema = makeExecutableSchema({
  typeDefs: [productListSchema, productDetailSchema],
  resolvers: [productListResolvers, productDetailResolvers],
});

Apollo Server インスタンスを作成し、データソースを設定します。

typescript/**
 * Apollo Server インスタンスを作成
 * スキーマとコンテキスト(データソース)を設定
 */
const server = new ApolloServer({
  schema,
  // 本番環境ではイントロスペクションを無効化
  introspection: process.env.NODE_ENV !== 'production',
});

/**
 * サーバーを起動
 * 各リクエストごとにデータソースのインスタンスを作成
 */
const startServer = async () => {
  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
    context: async () => ({
      dataSources: {
        productAPI: new ProductAPI(),
        userAPI: new UserAPI(),
      },
    }),
  });

  console.log(`🚀 Server ready at ${url}`);
};

startServer();

これで、画面別に最適化された GraphQL API サーバーが起動します。

フロントエンドでの利用

フロントエンド側では、画面ごとに最適化されたクエリを簡潔に記述できます。

typescript// pages/products/index.tsx
import { useQuery, gql } from '@apollo/client';

/**
 * 商品一覧取得クエリ
 * 一覧表示に必要な最小限のフィールドのみを指定
 */
const PRODUCT_LIST_QUERY = gql`
  query ProductList($page: Int, $category: String) {
    productList(page: $page, category: $category) {
      items {
        id
        name
        price
        thumbnailUrl
        inStock
      }
      totalCount
      hasNextPage
    }
  }
`;

/**
 * 商品一覧コンポーネント
 */
export default function ProductListPage() {
  const { data, loading, error } = useQuery(
    PRODUCT_LIST_QUERY,
    {
      variables: { page: 1 },
    }
  );

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

  return (
    <div>
      <h1>商品一覧</h1>
      <div className='product-grid'>
        {data.productList.items.map((product: any) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

このように、シンプルなクエリで必要なデータを取得でき、フロントエンド開発者の負担が大幅に軽減されます。

商品詳細画面のクエリも同様にシンプルです。

typescript// pages/products/[id].tsx
import { useQuery, gql } from '@apollo/client';
import { useRouter } from 'next/router';

/**
 * 商品詳細取得クエリ
 * 詳細画面に必要なすべての情報を一度に取得
 */
const PRODUCT_DETAIL_QUERY = gql`
  query ProductDetail($id: ID!) {
    productDetail(id: $id) {
      id
      name
      price
      description
      images
      inStock
      stockQuantity
      specifications {
        key
        value
      }
      reviews {
        averageRating
        totalCount
        items {
          id
          rating
          comment
          userName
          createdAt
        }
      }
      relatedProducts {
        id
        name
        price
        thumbnailUrl
      }
    }
  }
`;

/**
 * 商品詳細コンポーネント
 */
export default function ProductDetailPage() {
  const router = useRouter();
  const { id } = router.query;

  const { data, loading, error } = useQuery(
    PRODUCT_DETAIL_QUERY,
    {
      variables: { id },
      skip: !id, // ID がない場合はクエリをスキップ
    }
  );

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

  const product = data.productDetail;

  return (
    <div>
      <h1>{product.name}</h1>
      <ImageGallery images={product.images} />
      <p>{product.description}</p>
      <Price value={product.price} />
      <Stock
        inStock={product.inStock}
        quantity={product.stockQuantity}
      />
      <Specifications items={product.specifications} />
      <Reviews data={product.reviews} />
      <RelatedProducts items={product.relatedProducts} />
    </div>
  );
}

Contract Graph の運用フロー

開発プロセスに Contract Graph を組み込むことで、スキーマ変更の安全性を確保します。

以下の図は、Contract Graph を使った開発フローを示しています。

mermaidsequenceDiagram
  participant dev as 開発者
  participant git as Git
  participant ci as CI/CD
  participant apollo as Apollo Studio
  participant prod as 本番環境

  dev->>git: スキーマ変更をコミット
  git->>ci: Pull Request 作成
  ci->>apollo: スキーマチェック実行
  apollo-->>ci: 互換性チェック結果

  alt 互換性OK
    ci-->>git: ✅ チェック成功
    dev->>git: マージ実行
    git->>ci: デプロイ開始
    ci->>apollo: 新スキーマを登録
    ci->>prod: デプロイ実行
  else 破壊的変更あり
    ci-->>git: ❌ チェック失敗
    apollo-->>dev: 影響範囲を通知
    dev->>dev: スキーマ修正
  end

このフローにより、破壊的変更が本番環境に到達する前に検出され、安全なスキーマ進化が実現されます。

スキーマチェックスクリプト

typescript// scripts/check-schema.ts
import { execSync } from 'child_process';

/**
 * スキーマの互換性をチェック
 * Apollo Studio との連携により破壊的変更を検出
 */
async function checkSchema() {
  try {
    console.log('📋 スキーマの互換性をチェック中...');

    // Apollo CLI を使用してスキーマチェックを実行
    const result = execSync(
      'npx apollo service:check --endpoint=http://localhost:4000/graphql',
      { encoding: 'utf-8' }
    );

    console.log(result);
    console.log('✅ スキーマチェックが成功しました');
    process.exit(0);
  } catch (error: any) {
    console.error('❌ スキーマチェックが失敗しました');
    console.error(error.stdout);
    process.exit(1);
  }
}

checkSchema();

このスクリプトを CI/CD パイプラインに組み込むことで、自動的にスキーマの検証が行われます。

スキーマのバージョン管理

typescript// scripts/publish-schema.ts
import { execSync } from 'child_process';

/**
 * スキーマを Apollo Studio に公開
 * バージョンタグを付けて管理
 */
async function publishSchema() {
  const tag = process.env.SCHEMA_TAG || 'current';
  const variant =
    process.env.APOLLO_GRAPH_VARIANT || 'production';

  try {
    console.log(
      `📤 スキーマを公開中... (tag: ${tag}, variant: ${variant})`
    );

    // スキーマを Apollo Studio に公開
    const result = execSync(
      `npx apollo service:push \
        --endpoint=http://localhost:4000/graphql \
        --tag=${tag} \
        --variant=${variant}`,
      { encoding: 'utf-8' }
    );

    console.log(result);
    console.log('✅ スキーマの公開が完了しました');
  } catch (error: any) {
    console.error('❌ スキーマの公開が失敗しました');
    console.error(error.message);
    process.exit(1);
  }
}

publishSchema();

パフォーマンス最適化のポイント

画面別スキーマを活用する際、以下の最適化手法を適用することで、さらなるパフォーマンス向上が期待できます。

DataLoader による N+1 問題の解決

typescript// src/datasources/loaders.ts
import DataLoader from 'dataloader';
import { ProductAPI } from './ProductAPI';

/**
 * 商品情報を効率的にバッチ取得する DataLoader
 * 複数の商品 ID を一度のリクエストで取得し、N+1 問題を回避
 */
export function createProductLoader(
  productAPI: ProductAPI
) {
  return new DataLoader(async (ids: readonly string[]) => {
    // 複数の ID をまとめて取得
    const products = await productAPI.getProductsByIds([
      ...ids,
    ]);

    // ID の順序を保持して結果を返す
    const productMap = new Map(
      products.map((p) => [p.id, p])
    );

    return ids.map((id) => productMap.get(id) || null);
  });
}

DataLoader をコンテキストに追加します。

typescript// src/server.ts での DataLoader の使用
import { createProductLoader } from './datasources/loaders';

const server = new ApolloServer({
  schema,
  context: async () => {
    const productAPI = new ProductAPI();

    return {
      dataSources: {
        productAPI,
      },
      loaders: {
        // リクエストごとに新しい DataLoader を作成
        product: createProductLoader(productAPI),
      },
    };
  },
});

これにより、関連商品やレビューなど、複数の商品情報を効率的に取得できます。

キャッシュ戦略の実装

typescript// src/resolvers/productList.resolvers.ts
export const productListResolvers = {
  Query: {
    productList: async (
      _: any,
      args: ProductListArgs,
      { dataSources }: Context,
      info: GraphQLResolveInfo
    ) => {
      // キャッシュキーの生成
      const cacheKey = `productList:${args.page}:${args.limit}:${args.category}`;

      const result =
        await dataSources.productAPI.getProductList({
          page: args.page || 1,
          limit: args.limit || 20,
          category: args.category,
        });

      return {
        items: result.items,
        totalCount: result.total,
        hasNextPage:
          result.total >
          (args.page || 1) * (args.limit || 20),
        // キャッシュ制御のヒントを設定
        cacheControl: {
          maxAge: 300, // 5分間キャッシュ
          scope: 'PUBLIC',
        },
      };
    },
  },
};

エラーハンドリングとモニタリング

本番環境では、適切なエラーハンドリングとモニタリングが重要です。

typescript// src/utils/errorHandler.ts
import { ApolloServerErrorCode } from '@apollo/server/errors';

/**
 * カスタムエラークラス
 * エラーコードと詳細情報を含む
 */
export class BFFError extends Error {
  code: string;
  statusCode: number;
  details?: any;

  constructor(
    message: string,
    code: string = 'INTERNAL_SERVER_ERROR',
    statusCode: number = 500,
    details?: any
  ) {
    super(message);
    this.code = code;
    this.statusCode = statusCode;
    this.details = details;
  }
}

/**
 * データ取得エラー
 * Error 503: SERVICE_UNAVAILABLE
 */
export class DataFetchError extends BFFError {
  constructor(service: string, details?: any) {
    super(
      `${service} からのデータ取得に失敗しました`,
      'SERVICE_UNAVAILABLE',
      503,
      details
    );
  }
}

/**
 * バリデーションエラー
 * Error 400: BAD_USER_INPUT
 */
export class ValidationError extends BFFError {
  constructor(message: string, details?: any) {
    super(
      message,
      ApolloServerErrorCode.BAD_USER_INPUT,
      400,
      details
    );
  }
}

エラーハンドリングをリゾルバーに適用します。

typescript// src/resolvers/productDetail.resolvers.ts
import {
  DataFetchError,
  ValidationError,
} from '../utils/errorHandler';

export const productDetailResolvers = {
  Query: {
    productDetail: async (
      _: any,
      { id }: { id: string },
      { dataSources }: Context
    ) => {
      // 入力バリデーション
      if (!id || id.trim() === '') {
        throw new ValidationError('商品 ID は必須です', {
          field: 'id',
          value: id,
        });
      }

      try {
        const product =
          await dataSources.productAPI.getProductDetail(id);

        if (!product) {
          throw new ValidationError(
            `商品が見つかりません(ID: ${id})`,
            { field: 'id', value: id }
          );
        }

        return product;
      } catch (error: any) {
        // サービスエラーの場合
        if (error.statusCode >= 500) {
          throw new DataFetchError('商品サービス', {
            originalError: error.message,
            productId: id,
          });
        }
        throw error;
      }
    },
  },
};

Apollo Studio でのモニタリング設定

typescript// src/server.ts
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';

const server = new ApolloServer({
  schema,
  plugins: [
    // Apollo Studio への使用状況レポート
    ApolloServerPluginUsageReporting({
      sendVariableValues: { all: true },
      sendHeaders: { all: true },
      // エラー情報の送信
      sendErrors: {
        includeStacktraces:
          process.env.NODE_ENV !== 'production',
      },
    }),
  ],
});

これにより、Apollo Studio でクエリのパフォーマンス、エラー率、使用状況などを可視化できます。

まとめ

Apollo GraphQL を用いた BFF 開発において、画面別スキーマ設計と Contract Graph を併用することで、以下のメリットが得られます。

パフォーマンスの向上

各画面に最適化されたスキーマにより、不要なデータ取得を排除し、クエリのレスポンス時間を短縮できます。DataLoader などの最適化手法と組み合わせることで、N+1 問題も解消されます。

開発効率の改善

画面ごとに明確に定義されたスキーマにより、フロントエンド開発者は複雑なクエリを記述する必要がなくなります。また、画面単位での独立した開発とテストが可能になり、チーム全体の生産性が向上するでしょう。

安全なスキーマ進化

Contract Graph によるスキーマチェックを CI/CD パイプラインに組み込むことで、破壊的変更を事前に検出できます。これにより、本番環境でのエラーを未然に防ぎ、安全なスキーマの進化が実現されます。

保守性の向上

画面別にスキーマとリゾルバーを分離することで、コードの見通しが良くなり、保守性が向上します。新しい画面の追加や既存画面の変更も、他の画面への影響を最小限に抑えながら実施できます。

本記事で紹介した手法を活用することで、スケーラブルで保守性の高い BFF アーキテクチャを構築できます。まずは小規模な画面から始めて、徐々に適用範囲を広げていくことをお勧めします。

関連リンク