T-CREATOR

Apollo スキーマの進化設計:非破壊変更・ディプレケーション・ロールアウト戦略

Apollo スキーマの進化設計:非破壊変更・ディプレケーション・ロールアウト戦略

GraphQL API を運用する上で、スキーマの進化は避けて通れない課題です。新機能の追加や既存機能の改善を行いながら、既存のクライアントアプリケーションを壊さないようにする必要があります。Apollo GraphQL では、非破壊的な変更、ディプレケーション、段階的なロールアウトという 3 つの戦略を組み合わせることで、安全にスキーマを進化させることができます。

この記事では、Apollo を使ったスキーマ進化の具体的な設計手法と実装方法を、実践的なコード例とともに解説していきます。

背景

GraphQL スキーマ進化の重要性

GraphQL API は、モバイルアプリや Web アプリケーションなど、複数のクライアントから利用されることが一般的です。これらのクライアントは、それぞれ異なるリリースサイクルを持っており、すべてのクライアントを同時に更新することは現実的ではありません。

そのため、API 側のスキーマ変更は、既存のクライアントに影響を与えない形で行う必要があります。この考え方は「後方互換性の維持」と呼ばれ、安定した API 運用の基本原則となっています。

Apollo によるスキーマ管理の利点

Apollo Server と Apollo Studio を組み合わせることで、スキーマの変更履歴の追跡、影響範囲の分析、段階的なロールアウトが可能になります。これにより、開発チームは自信を持ってスキーマを進化させることができるでしょう。

以下の図は、Apollo を用いたスキーマ進化の全体フローを示しています。

mermaidflowchart TB
  dev["開発者"] -->|スキーマ変更| schema["スキーマ定義"]
  schema -->|検証| apollo["Apollo Studio"]
  apollo -->|影響分析| check["Breaking Change<br/>チェック"]
  check -->|問題なし| deploy["デプロイ"]
  check -->|問題あり| fix["修正・ディプレケーション"]
  fix --> schema
  deploy -->|段階的| rollout["ロールアウト"]
  rollout -->|モニタリング| monitor["使用状況監視"]
  monitor -->|完全移行確認| cleanup["古いフィールド削除"]

この図から、スキーマ変更がデプロイされるまでの各ステップと、安全性を確保するためのチェックポイントが確認できます。

課題

スキーマ変更における典型的な問題

GraphQL スキーマを変更する際には、以下のような課題に直面します。

既存クライアントへの影響

フィールドの削除や型の変更は、既存のクエリを壊してしまう可能性があります。特に、モバイルアプリのように更新が遅いクライアントでは、古いバージョンが長期間利用され続けることがあるでしょう。

変更の影響範囲が不明確

どのクライアントがどのフィールドを使用しているかを把握していないと、変更の影響範囲を正確に判断できません。これにより、意図せず重要な機能を壊してしまうリスクが生じます。

段階的な移行の難しさ

新しい API 仕様への移行を一度に行うことは困難です。旧仕様と新仕様を並行稼働させながら、徐々にクライアントを移行させる仕組みが必要になります。

以下は、破壊的変更の種類を整理した表です。

#変更の種類具体例影響度
1フィールドの削除user.email フィールドを削除★★★ 高
2フィールドの型変更age: Intage: String★★★ 高
3必須引数の追加users(limit: Int)users(limit: Int!)★★★ 高
4nullable から non-null への変更name: Stringname: String!★★☆ 中
5Enum 値の削除Status.PENDING を削除★★☆ 中

これらの変更を安全に行うためには、適切な戦略と実装が不可欠です。

解決策

非破壊的変更の原則

Apollo では、スキーマを安全に進化させるための原則が確立されています。基本となるのは「既存のフィールドを削除せず、新しいフィールドを追加する」という additive な変更です。

フィールドの追加は安全

新しいフィールドやオプショナルな引数を追加することは、既存のクライアントに影響を与えません。クライアントは新しいフィールドを使うかどうかを選択できます。

ディプレケーションによる段階的な廃止

古いフィールドを即座に削除するのではなく、@deprecated ディレクティブを使って非推奨としてマークします。これにより、クライアントに移行の時間を与えることができるでしょう。

バージョニングではなく進化

REST API のような ​/​v1​/​users​/​v2​/​users といったバージョニングではなく、単一のスキーマを継続的に進化させるアプローチを取ります。

Apollo Studio による変更の検証

Apollo Studio の Schema Check 機能を使うことで、スキーマ変更が既存のクエリに与える影響を事前に検証できます。これは CI/CD パイプラインに組み込むことで、自動的なチェックが可能になります。

以下の図は、Schema Check のワークフローを示しています。

mermaidflowchart LR
  pr["Pull Request"] -->|トリガー| ci["CI/CD"]
  ci -->|スキーマ送信| studio["Apollo Studio"]
  studio -->|過去のクエリと比較| analysis["影響分析"]
  analysis -->|結果| report["レポート生成"]
  report -->|Breaking Change 検出| fail["❌ チェック失敗"]
  report -->|問題なし| success["✅ チェック成功"]
  success --> merge["マージ可能"]
  fail --> review["レビュー・修正"]

この仕組みにより、破壊的変更を本番環境にデプロイする前に検出できます。

ロールアウト戦略

段階的なロールアウトは、変更のリスクを最小化するための重要な戦略です。Apollo Router や Apollo Server のディレクティブ機能を活用することで、特定のクライアントグループに対してのみ新しいスキーマを公開できます。

具体例

非破壊的な変更の実装

実際のコード例を通して、非破壊的な変更方法を見ていきましょう。

シナリオ: ユーザー名フィールドの統合

現在、firstNamelastName を別々のフィールドで提供していますが、新しく fullName フィールドを追加し、将来的には古いフィールドを廃止したいケースを考えます。

Step 1: 新しいフィールドの追加

まず、既存のフィールドはそのままに、新しい fullName フィールドを追加します。

typescriptimport { gql } from 'apollo-server';

const typeDefs = gql`
  type User {
    id: ID!
    # 既存のフィールド(後で非推奨にする)
    firstName: String!
    lastName: String!
    # 新しく追加するフィールド
    fullName: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
  }
`;

この変更は、既存のクライアントに一切影響を与えません。

Step 2: リゾルバーの実装

新しいフィールドのリゾルバーを実装します。既存のデータから fullName を生成します。

typescriptconst resolvers = {
  User: {
    // 新しいフィールドのリゾルバー
    fullName: (parent) => {
      return `${parent.firstName} ${parent.lastName}`;
    },
  },
  Query: {
    user: async (_, { id }, { dataSources }) => {
      // データソースからユーザー情報を取得
      return await dataSources.userAPI.getUserById(id);
    },
  },
};

リゾルバーは、既存の firstNamelastName から fullName を計算して返します。

Step 3: 古いフィールドの非推奨化

新しいフィールドが安定稼働したら、古いフィールドに @deprecated ディレクティブを追加します。

typescriptconst typeDefs = gql`
  type User {
    id: ID!
    # 非推奨としてマーク
    firstName: String!
      @deprecated(
        reason: "fullName フィールドを使用してください"
      )
    lastName: String!
      @deprecated(
        reason: "fullName フィールドを使用してください"
      )
    # 推奨される新しいフィールド
    fullName: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
  }
`;

@deprecated ディレクティブは、GraphQL のイントロスペクションで確認でき、開発者ツールでも警告が表示されます。

ディプレケーション管理の実装

使用状況のトラッキング

Apollo Studio では、各フィールドの使用頻度を追跡できます。これにより、非推奨フィールドがまだ使われているかを確認できるでしょう。

typescriptimport { ApolloServer } from 'apollo-server';
import { ApolloServerPluginUsageReporting } from 'apollo-server-core';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    // Apollo Studio へ使用状況をレポート
    ApolloServerPluginUsageReporting({
      sendVariableValues: { all: true },
      sendHeaders: { all: true },
    }),
  ],
});

このプラグインにより、フィールドごとの使用統計が Apollo Studio に送信されます。

カスタムディレクティブでの警告実装

独自のディレクティブを作成し、非推奨フィールドの使用時にログを出力することもできます。

typescriptimport {
  mapSchema,
  getDirective,
  MapperKind,
} from '@graphql-tools/utils';
import {
  defaultFieldResolver,
  GraphQLSchema,
} from 'graphql';

// カスタム @deprecated ディレクティブの実装
function deprecatedDirective(directiveName: string) {
  return (schema: GraphQLSchema) => {
    return mapSchema(schema, {
      [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
        const deprecatedDirective = getDirective(
          schema,
          fieldConfig,
          directiveName
        )?.[0];

        if (deprecatedDirective) {
          const { resolve = defaultFieldResolver } =
            fieldConfig;
          const { reason } = deprecatedDirective;

          fieldConfig.resolve = async function (
            source,
            args,
            context,
            info
          ) {
            // 非推奨フィールド使用時のログ出力
            console.warn(
              `⚠️ 非推奨フィールドが使用されました: ${info.parentType.name}.${info.fieldName}`,
              `理由: ${reason}`
            );

            return resolve(source, args, context, info);
          };
        }

        return fieldConfig;
      },
    });
  };
}

このディレクティブは、非推奨フィールドが使用されるたびに警告を出力します。

typescriptimport { makeExecutableSchema } from '@graphql-tools/schema';

// スキーマにディレクティブを適用
let schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});

schema = deprecatedDirective('deprecated')(schema);

const server = new ApolloServer({
  schema,
});

これにより、どのクライアントが古いフィールドを使っているかをリアルタイムで把握できます。

Schema Check の CI/CD 統合

GitHub Actions を使って、Pull Request 作成時に自動的に Schema Check を実行する例を見ていきましょう。

GitHub Actions ワークフローの設定

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

on:
  pull_request:
    paths:
      - 'src/schema/**'
      - 'src/resolvers/**'

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

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

      - name: Install dependencies
        run: yarn install

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

このワークフローは、スキーマ関連のファイルが変更された際に自動実行されます。

Apollo CLI の設定

プロジェクトルートに Apollo の設定ファイルを作成します。

javascript// apollo.config.js
module.exports = {
  service: {
    name: 'my-graphql-api',
    endpoint: {
      url: 'http://localhost:4000/graphql',
    },
  },
};

この設定により、Apollo CLI がスキーマをチェックする際の接続先が定義されます。

スキーマ公開コマンドの実装

変更が承認されたら、新しいスキーマを Apollo Studio に公開します。

json// package.json
{
  "scripts": {
    "schema:check": "apollo service:check --endpoint=http://localhost:4000/graphql",
    "schema:push": "apollo service:push --endpoint=http://localhost:4000/graphql"
  }
}

これらのスクリプトを使うことで、スキーマの検証と公開が簡単に行えます。

フィーチャーフラグによるロールアウト

特定の機能を段階的にロールアウトするために、フィーチャーフラグを実装します。

コンテキストベースのフィールド制御

typescriptimport { ApolloServer } from 'apollo-server';

interface Context {
  user?: {
    id: string;
    betaFeatures: boolean;
  };
}

const typeDefs = gql`
  type User {
    id: ID!
    fullName: String!
    # ベータ機能: プロフィール画像 URL
    avatarUrl: String
  }

  type Query {
    user(id: ID!): User
  }
`;

コンテキスト情報に基づいて、フィールドの表示を制御するスキーマです。

typescriptconst resolvers = {
  User: {
    avatarUrl: (parent, args, context: Context) => {
      // ベータ機能フラグをチェック
      if (!context.user?.betaFeatures) {
        return null; // ベータユーザー以外には返さない
      }

      return parent.avatarUrl;
    },
  },
  Query: {
    user: async (_, { id }, { dataSources }) => {
      return await dataSources.userAPI.getUserById(id);
    },
  },
};

リゾルバーレベルでフィーチャーフラグを確認し、適切なレスポンスを返します。

Apollo Server のコンテキスト設定

リクエストヘッダーからユーザー情報を取得し、コンテキストに設定します。

typescriptconst server = new ApolloServer({
  typeDefs,
  resolvers,
  context: async ({ req }) => {
    // 認証トークンからユーザー情報を取得
    const token = req.headers.authorization || '';
    const user = await getUserFromToken(token);

    return {
      user,
    };
  },
});

// トークンからユーザー情報を取得する関数
async function getUserFromToken(token: string) {
  if (!token) return null;

  try {
    const decoded = await verifyToken(token);
    return {
      id: decoded.userId,
      betaFeatures: decoded.betaFeatures || false,
    };
  } catch (error) {
    console.error('トークン検証エラー:', error);
    return null;
  }
}

認証情報に基づいて、各ユーザーが利用できる機能を動的に制御できます。

段階的な移行プロセスの実装

実際の移行プロセスを管理するための実装例を見ていきます。

移行状況の監視

typescriptimport { ApolloServer } from 'apollo-server';
import { ApolloServerPluginInlineTrace } from 'apollo-server-core';

// カスタムプラグインで使用状況を追跡
const fieldUsageTrackingPlugin = {
  async requestDidStart() {
    return {
      async willSendResponse({
        response,
        context,
        operationName,
      }) {
        // 非推奨フィールドの使用を記録
        const deprecatedFieldsUsed =
          context.deprecatedFieldsUsed || [];

        if (deprecatedFieldsUsed.length > 0) {
          console.log(
            `🔔 操作 "${operationName}" で非推奨フィールドが使用されました:`,
            {
              fields: deprecatedFieldsUsed,
              clientName: context.clientName,
              timestamp: new Date().toISOString(),
            }
          );
        }
      },
    };
  },
};

このプラグインは、非推奨フィールドの使用状況をログに記録します。

typescriptconst server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginInlineTrace(),
    fieldUsageTrackingPlugin,
  ],
  context: ({ req }) => ({
    clientName: req.headers['x-client-name'] || 'unknown',
    deprecatedFieldsUsed: [],
  }),
});

クライアント名とともに使用状況を追跡することで、どのクライアントが移行を完了していないかを把握できます。

自動通知システムの実装

非推奨フィールドの使用が検出された際に、開発チームに通知を送る仕組みを実装します。

typescriptimport axios from 'axios';

interface DeprecatedFieldAlert {
  fieldName: string;
  clientName: string;
  count: number;
  timestamp: Date;
}

// Slack への通知関数
async function sendDeprecationAlert(
  alert: DeprecatedFieldAlert
) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;

  if (!webhookUrl) {
    console.warn('Slack Webhook URL が設定されていません');
    return;
  }

  const message = {
    text: '⚠️ 非推奨フィールドの使用が検出されました',
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*非推奨フィールド:* \`${alert.fieldName}\`\n*クライアント:* ${alert.clientName}\n*使用回数:* ${alert.count}`,
        },
      },
    ],
  };

  try {
    await axios.post(webhookUrl, message);
  } catch (error) {
    console.error('Slack 通知エラー:', error);
  }
}

この関数は、非推奨フィールドの使用を Slack チャンネルに通知します。

typescript// 使用頻度を集計して定期的に通知
const deprecationTracker = new Map<
  string,
  DeprecatedFieldAlert
>();

setInterval(async () => {
  for (const [key, alert] of deprecationTracker.entries()) {
    if (alert.count > 0) {
      await sendDeprecationAlert(alert);
      // 通知後はカウントをリセット
      alert.count = 0;
    }
  }
}, 60000); // 1分ごとにチェック

定期的な集計により、チームは移行の進捗状況を継続的に把握できるでしょう。

完全な移行フローの例

最後に、フィールド追加から削除までの完全なフローをまとめます。

以下の図は、移行プロセス全体のタイムラインを示しています。

mermaidflowchart TD
  start["Week 1: 新フィールド追加"] --> monitor1["Week 2-4: 使用状況監視"]
  monitor1 --> deprecate["Week 5: 旧フィールド非推奨化"]
  deprecate --> notify["Week 6-8: クライアント通知<br/>移行サポート"]
  notify --> check["Week 9-10: 使用状況確認"]
  check --> decision{旧フィールドの<br/>使用ゼロ?}
  decision -->|Yes| remove["Week 11: 旧フィールド削除"]
  decision -->|No| extend["移行期間延長"]
  extend --> notify
  remove --> done["移行完了"]

このフローにより、安全かつ計画的にスキーマを進化させることができます。

Week 1: 新フィールドの追加とデプロイ

typescript// 新しい fullName フィールドを追加
type User {
  id: ID!
  firstName: String!
  lastName: String!
  fullName: String!  # 新規追加
}

Week 2-4: 使用状況の監視とクライアントへの案内

Apollo Studio で fullName フィールドの採用状況を確認します。ドキュメントや社内 Wiki で新しいフィールドの使用を推奨しましょう。

Week 5: 旧フィールドの非推奨化

typescripttype User {
  id: ID!
  firstName: String! @deprecated(reason: "fullName を使用してください。2024年3月1日に削除予定")
  lastName: String! @deprecated(reason: "fullName を使用してください。2024年3月1日に削除予定")
  fullName: String!
}

Week 6-8: アクティブな通知とサポート

非推奨フィールドを使用しているクライアントのオーナーに直接連絡し、移行をサポートします。必要に応じて、移行ガイドや技術サポートを提供しましょう。

Week 9-10: 最終確認

Apollo Studio で旧フィールドの使用状況を確認します。使用がゼロになっていることを確認したら、削除の準備を進めます。

Week 11: 旧フィールドの削除

typescripttype User {
  id: ID!
  fullName: String!  # 旧フィールドは完全に削除
}

この時点で、すべてのクライアントが新しいフィールドに移行完了しているため、安全に削除できます。

まとめ

Apollo GraphQL を使ったスキーマの進化設計では、非破壊的変更、ディプレケーション、段階的なロールアウトという 3 つの戦略を組み合わせることが重要です。

非破壊的変更の原則を守ることで、既存のクライアントに影響を与えずに新機能を追加できます。新しいフィールドを追加し、古いフィールドは即座に削除せず、@deprecated ディレクティブでマークすることが基本となるでしょう。

Apollo Studio による変更の検証を活用することで、スキーマ変更の影響範囲を事前に把握できます。Schema Check を CI/CD パイプラインに組み込むことで、破壊的変更を本番環境にデプロイする前に検出できるため、安心して開発を進められます。

段階的なロールアウトにより、変更のリスクを最小化できます。フィーチャーフラグやコンテキストベースの制御を使うことで、特定のユーザーグループに対してのみ新機能を公開し、問題がないことを確認してから全体に展開できます。

これらの手法を組み合わせることで、GraphQL API を安全に進化させ、開発者とユーザーの両方に優れた体験を提供できるでしょう。スキーマの進化は継続的なプロセスですので、チーム内でベストプラクティスを共有し、改善を続けることが成功の鍵となります。

関連リンク