以下の内容はhttps://creators-note.chatwork.com/entry/2025/12/12/120000より取得しました。


インターフェース負債を作らない。既存スキーマを維持したままFeatureFlagを実現するAPI設計

はじめまして。kubell でバックエンドエンジニアをしている佐藤と申します。

この記事は kubellアドベントカレンダー2025 12/12 の投稿です。

はじめに

kubell では現在、 Chatwork のシステムをモノリスから小さなシステムへと段階的に移行しています。詳しくは 開発生産性 Conference 2024 での発表 をご覧ください。

この移行の一環として、 クライアントの接続先を現行 API から新規 API に徐々に繋ぎ変えているのですが、ここでひとつ悩ましい問題がでてきました。

「もし新規APIに問題があったら、すぐに現行 API に戻せるようにしたい」

ビジネスチャットはシステムダウンが許されないサービスです。だからこそ、移行中も「いつでも切り戻せる」状態を保ちたい。そこで登場するのが FeatureFlag を使ったロールアウト制御です。

本記事では、この FeatureFlag をクライアントにどう渡すか、チームで検討した過程を紹介します。

段階的な移行を助ける「切り戻し設計」

やりたいことはシンプルです。

クライアントの期待する挙動

クライアントは新規APIにリクエストを送ります。 新規APIは「ロールアウト対象かどうか」の情報をレスポンスに含めて返し、クライアントは対象外であれば現行APIにフォールバックします。

こうしておけば、新規APIが返す値がおかしかったり、その値を使うクライアント側の処理にバグがあったりしても、サーバー側でロールアウト対象を絞ることで影響範囲を抑えられます。

ここで問題になるのが、この「ロールアウト対象かどうか」をどうやって伝えるかです。

ということで、FeatureFlag をどう渡そうか検討していきましょう。

検討1: boolean フラグを追加しよう

真っ先に思いつく方法ですね。レスポンスに is_rollout_target のようなフラグを追加して、クライアントに判断させる作戦です。

ロールアウト対象の場合:

{
  "is_rollout_target": true,
  "data": {
    "hoge": "fuga"
  }
}

ロールアウト対象外の場合:

{
  "is_rollout_target": false,
  "data": {
    "hoge": "fuga"
  }
}

クライアントはこのフラグを見て、true なら新しい処理、false なら現行APIへフォールバック。シンプルでわかりやすいですね。

では、めでたくロールアウトが完了したとしましょう。レスポンスはどうなるでしょうか?

{
  "is_rollout_target": true,
  "data": {
    "hoge": "fuga"
  }
}

...変わりませんね。役目を終えたはずのロールアウトフラグが残っています。

なぜでしょう? 古いクライアントがまだこのフラグを参照しているからです。 Chatwork はビジネス向けチャットである特性上、ユーザー様の環境によっては古いクライアントアプリを使い続けることもあり、サーバー側でフラグを消してしまうとそのようなクライアントが壊れる可能性があります。「まあ、残しておいても害はないし...」ということで、フラグはそのまま残すことにしましょう。

さて、半年が経ちました。この API に手を加える新しい施策が始まります。担当チームがレスポンスのスキーマを確認すると、こう聞いてくるでしょう。

「この is_rollout_target ってなんですか?消していいんですか?」

本来、このフラグの経緯なんて知らなくていいはずの人たちです。でも説明しないわけにはいきません。そしてこのやりとりは、次に誰かがこのAPIを触るときにも繰り返されます。

一時的なはずのフラグが、永続的な負債になる。 これがこのアプローチの最大の問題点です。

検討2: data を nullable にしよう

検討1でフラグがゴミになる問題がわかりました。じゃあフラグを追加するのはやめて、data フィールド自体を nullable にして対応してみましょう。

ロールアウト対象の場合:

{
  "data": {
    "hoge": "fuga"
  }
}

ロールアウト対象外の場合:

{
  "data": null
}

クライアントは data が null かどうかをチェックして、null なら現行APIへフォールバックします。

ロールアウトが完了したらどうなるでしょう?

サーバーは常に data に値を返すようになります。スキーマ上で data は non-nullable に変更されるでしょう。 古いクライアントの null チェック処理は残りますが、null が返ってこない以上、正常系のパスを通るだけです。実害はありません。

検討1と違って、ロールアウト完了後にスキーマの負債を完全に解消できるのが大きなメリットです。半年後に別のチームがこのAPIを見ても、スキーマはクリーンな状態。「このフラグなに?」という質問は発生しません。

...ただし、このアプローチには前提条件があります。 「data は本来 null を許容しない」という前提が必要です。 もともと null が有効な値として使われている API には適用できません。ロールアウト対象外を表す null と、本来の意味での null が区別できなくなってしまうからです。

検討3: いっそのこと、フラグ配信を専用システムにしよう

ここで発想を転換してみましょう。検討1・2はどちらもAPIのレスポンスをいじる方法でした。じゃあ、そもそもスキーマに手を加えなければいいのでは? 例えば、ロールアウトフラグを配信する専用のシステムを用意し、クライアントはAPI呼び出しの前にそのフラグを取得して分岐する、という作戦です。

[クライアント] → [配信システムからフラグ取得] → フラグに応じて新API or 旧API を呼び分け

APIのスキーマはクリーンなまま。一見よさそうですね。

...ところが、ロールアウトが完了して「じゃあフラグ配信やめよう」となったとき、例のごとく古いクライアントが利用しているので配信システムの退役ができません。

結局、スキーマを汚さない代わりに、別の場所に負債が移っただけでした。

検討4: API で専用エラーを返そう

検討3で別システムに切り出すアプローチの欠点が見えました。やはりAPIエンドポイント自体でどうにかする方向で考えてみましょう。

そこで、ロールアウト対象外の場合に専用のエラーを返すという方法はどうでしょうか。

ロールアウト対象の場合:

{
  "data": {
    "hoge": "fuga"
  }
}

ロールアウト対象外の場合:

{
  "error": "NOT_IN_ROLLOUT_FOR_HOGEFUGA"
}

クライアントはエラーコードを見て、専用エラーなら現行APIへフォールバックします。エラーハンドリングの一環として実装できるので、処理の流れとしても自然ですね。

ロールアウトが完了したらどうなるでしょう?サーバーはエラーを返さなくなり、常に正常系のレスポンスを返します。旧クライアントのエラーハンドリング処理は残りますが、エラーが返ってこない以上、そのコードパスは通りません。検討2と同様、実害なしです。

サーバー側もエラー返却処理を安全に削除できます。スキーマに余計なフィールドは残りません。唯一、「このエラーコードは使用済みであり、再利用してはいけない」ということをサーバーサイドチームが覚えておく必要がありますが、 この知識はスキーマ管理者であるサーバーサイドチームに閉じ、スキーマには現れません。 嬉しいですね。

さらに、このアプローチには検討2にはないメリットがあります。正常系で data が null を許容するAPIにも適用できる点です。

{
  "data": null
}

検討2では、この null が「ロールアウト対象外」なのか「正常系としての null」なのか区別がつきませんでした。エラーレスポンスとして分離することで、その問題を回避できます。

検討5: レスポンスヘッダを使おう

検討4で、スキーマをいじらずに解決する方法がよさそうだとわかりました。エラーレスポンスを使う以外にも、似たようなアプローチがあります。

レスポンスヘッダを使う方法です。 ロールアウト対象外のときのみ、レスポンスヘッダに切り戻しフラグを追加します。

ロールアウト対象の場合:

HTTP/1.1 200 OK
Content-Type: application/json

{ "data": { "hoge": "fuga" } }

ロールアウト対象外の場合:

HTTP/1.1 200 OK
Require-Fallback-For-Hogefuga: true
Content-Type: application/json

{ "data": { "hoge": "fuga" } }

クライアントはレスポンスヘッダを確認し、切り戻しフラグがあれば現行APIへフォールバック、なければそのまま新APIのレスポンスを使います。

ロールアウトが完了したらどうなるでしょう?レスポンスにヘッダが付与されなくなります。旧クライアントのヘッダ確認処理は残りますが、ヘッダが存在しなければ正常系として処理されるので、実害はありません。検討2・4と同じパターンですね。

サーバー側もヘッダ追加処理を安全に削除できます。レスポンスボディのスキーマは最初から最後まで一切汚れません。

...ただし、このアプローチには議論の余地があります。 アプリケーションの関心事がインフラレイヤに波及している点です。「ロールアウト対象かどうか」という判断結果を、HTTPヘッダという比較的低レイヤな場所に置くことになります。

チームによっては「ヘッダはインフラ的な情報を置く場所であって、アプリケーションの状態を表現する場所ではない」という設計方針を持っているかもしれません。採用する前に、チーム内で認識を揃えておくとよさそうです。

また、クライアントの実装によってはレスポンスヘッダへのアクセスが煩雑になるケースもあります。使用しているHTTPクライアントライブラリとの相性は事前に確認しておきましょう。

検討のまとめと、チームの決定

ここまでの検討を表にまとめてみましょう。

検討 ロールアウト期間中 ロールアウト終了時
1. boolean フラグ サーバー: boolean フラグを返す
クライアント: フラグを見て分岐
新クライアント: フラグを無視する実装が必要
旧クライアント: フラグ参照処理が残り続ける
サーバー: フラグ返却処理を削除不可
2. data を nullable に サーバー: 対象外なら data: null を返す
クライアント: null なら現行APIへフォールバック
新クライアント: null チェック不要
旧クライアント: null チェック処理が残るが、null が来なければ正常に動作
サーバー: スキーマを null 禁止に変更
3. フラグ配信システム サーバー: 専用システムでフラグを配信
クライアント: フラグを取得して分岐
新クライアント: フラグ取得処理は実装不要
旧クライアント: フラグ取得処理が残り続ける
サーバー: フラグ配信システムが退役不可
4. 専用エラー サーバー: 対象外なら専用エラーを返す
クライアント: エラーなら現行APIへフォールバック
新クライアント: エラーハンドリング不要
旧クライアント: エラーハンドリング処理が残るが、エラーが来なければ正常に動作
サーバー: エラー返却処理を削除可能
5. レスポンスヘッダ サーバー: 対象外ならヘッダを追加
クライアント: ヘッダを見て分岐
新クライアント: ヘッダ確認処理は実装不要
旧クライアント: ヘッダ確認処理が残るが、ヘッダが来なければ正常に動作
サーバー: ヘッダ追加処理を削除可能

議論の末、私たちのチームでは 「検討4: 専用エラーを返す」 を採用することにしました。 以下が決め手です。

  • null に特別な意味を持たせずに済む
  • ロールアウト終了後にスキーマに負債が残らない
  • クライアントの実装のしやすさ

FeatureFlag ひとつの話ではありますが、「小さいけど後々効いてくる設計判断」として、丁寧にチームで議論ができました。

最後に

一度スキーマに入れたものを消すのは本当に難しいです。だからこそ、将来を考えた設計が大切だと思います。

しがらみのないスキーマを保てるよう、責任を持ったスキーマ設計にチーム一丸で取り組んでまいります!

kubellではエンジニアを絶賛募集中です。ご興味をお持ちの方は、ぜひ一度お話させてください。

www.kubell.com

次回は iOS アプリ開発グループ 基盤開発チームの cw-terry さんです!お楽しみに!




以上の内容はhttps://creators-note.chatwork.com/entry/2025/12/12/120000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14