二度目の訪問
前回、Gateway を置いたら解決した。
あのスプレッドシートを最後に開いた日から2ヶ月が経った。CheckoutController の依存は gateway だけになり、バックエンドのエンドポイントが変わっても、もうルーティングテーブルだけを直せばいい。それは今も正しく動いていた。
次の問題は別の場所から来た。
「モバイルチームから苦情が来てるんですけど」と同僚が言ったのは、先週の金曜だった。「Gateway 経由の /product/detail のレスポンスが重すぎるって。帯域が厳しいらしくて、いくつかのフィールド削れないかって」
僕は Gateway のコードを開いた。商品詳細は10フィールドを返していた。モバイルアプリが使うのは5フィールドだった。残り5フィールドは、Web の管理画面専用のデータだった。
逆もあった。モバイルが欲しい「関連おすすめ商品」は、Gateway に実装していない。Web には不要だったから。今は、モバイルが別の API を追加で呼んでいた。
田中さんに聞いたら「またロックさんのとこ行けば」と即答だった。
雑居ビルの三階に来たのは初めてだった。前回はロックさんが来てくれた。階段の手すりに触れながら登っていると、「来たことのない場所に、二度目が来る理由」について考えた。設計の問題かもしれない、と思いながら来たのも初めてだった。
ドアを押すと、想像していたより静かな部屋だった。エナジードリンクの缶が2本。デスクは片付いている。ロックさんは何かを読んでいて、顔を上げなかった。
「ワトソン君。前回の窓口が、今度は容疑者になったかね」
僕はまだ何も言っていない。
「……田中さんに言われてまた来ました。モバイルのチームから苦情が来ています」
ロックさんが読んでいたものを伏せて、椅子を回した。
「座りたまえ。何が重いと言っている」
余分と不足が同時に起きている
「Gateway 経由の商品詳細 API です」
ラップトップを開いて、ProductService.get_product が返す構造を示した。
| |
「10フィールドです。Web の管理画面は在庫数、倉庫コード、入荷予定日、カテゴリパスを使います。モバイルアプリは商品名、価格、サムネイルと在庫の有無だけ」
「モバイルが使うのは何フィールドかね」
「5つです」
ロックさんが無言でフィールドを数えた。「残り5つは?」
「Web 専用のデータです。モバイルには使いません」
「それだけかね」
「逆もあります」と続けた。「モバイルは関連商品のリストが必要なんですが、Web には不要なので Gateway に実装していない。今はモバイルアプリが別の API を追加で呼んでいます」
ロックさんが立ち上がり、ホワイトボードに書いた。
| |
「これは Gateway の失敗かね」
少し間があった。来る前から、ずっと考えていた問いだった。
「……それを確かめに来ました。前回の Gateway は間違っていたんですか」
「間違いではない」ロックさんはそう言って、ホワイトボードの Gateway を指した。「前回、窓口を一本化した。依存の被害半径を止めた。それは今も生きている。だが一手で終わる問題ではなかった」
「では今回の問題は何ですか」
「窓口が全員に同じ答えを配っていることだよ。容疑者の名前は One-size-fits-all API だ——一枚の制服で全員を満足させようとした結果、誰にも合わない制服が生まれた」
モバイルは5フィールドのために10フィールドを受け取り続け、必要な関連商品のためだけに追加のリクエストを投げ続けている。
「Over-fetching と Under-fetching が、同じ API で同時に起きている事件だよ、ワトソン君」
それぞれの玄関を立てる
「解決策はクライアントの数だけ窓口を立てることだ」
「……また増やすんですか。前回は一本にしたのに」
「統一したのは依存の被害半径だよ。今度は分けるのはレスポンスの形だ。同じ問題を解いているわけではない」
少し考えてから、理解した気がした。Gateway が解いたのは「誰もが 4 サービスを直接知っている」問題だった。今回は「同じ答えを全員に渡している」問題だ。違う問題だ。
「統一は問題を解いた。分化は問題に応える」ロックさんが静かに言った。「順序が違うだけで、矛盾ではない」
まず共通の約束ごとを作る
「最初に役割の契約を定義する」
| |
「BFF::Role::Builder を使うクラスは、必ず build_product_response を実装しなければならない。WebのBFFもMobileのBFFも、同じ問いに答える義務を持つ。答え方だけが違う」
「Roleを使うのは、共通インターフェースを保証するためですか」
「そうだよ。どちらのBFFも build_product_response を持つと約束してある。契約のない分化は、ただの散らかりだ」
Web専用バックエンド
| |
「WebBff は管理画面が必要とする8フィールドを選んで返す。フィールドを選ぶ判断は、管理画面を一番知っているチームが持つ」
Mobile専用バックエンド
| |
「MobileBff は必要な5フィールドだけ、そして関連商品を一緒に返す。モバイルアプリが別途 API を呼ぶ必要はなくなる」
in_stock に気づいた。「stock_count をそのまま渡していない。0より大きければ 1、そうでなければ 0 に変換してますね」
「モバイルアプリは在庫数を画面に表示しない。在庫があるかどうかだけ知ればいい。形を整えるのがBFFの仕事だよ」
「では、認証はどうなりますか。前回 Gateway に集約したけれど、BFFを立てたら認証も分散しませんか」
「BFFの仕事はデータの形を整えることだよ。認証は今も Gateway が担う。BFFは upstream として既存の仕組みを使えばいい。共通処理を重複させる必要はない」
「つまり Gateway を消すのではなく」
「消さない。Gateway はルーティングと認証を担い続ける。BFFは Gateway の後ろ、またはその横でデータを整える。役割を分担するんだよ」
構造の変化
Before と After で依存の形がどう変わるかを整理すると、変化が見えやすくなります。
Before: 全クライアントが同一レスポンスを受け取る
classDiagram
class WebClient {
+get_product(id)
}
class MobileClient {
+get_product(id)
+get_recommendations(id)
}
class ApiGateway {
+handle(token, path, args)
}
class ProductService {
+get_product()
}
class RecommendationService {
+get_similar()
}
WebClient --> ApiGateway : 全10フィールド
MobileClient --> ApiGateway : 全10フィールド
MobileClient --> RecommendationService : 別途呼び出し
ApiGateway --> ProductService
After: 各クライアントが専用BFFを持つ
classDiagram
class WebClient {
+get_product(id)
}
class MobileClient {
+get_product(id)
}
class WebBff {
+upstream
+build_product_response(id)
}
class MobileBff {
+upstream
+recommendation_service
+build_product_response(id)
}
class ApiGateway {
+handle(token, path, args)
+get_product(id)
}
class ProductService {
+get_product()
}
class RecommendationService {
+get_similar()
}
WebClient --> WebBff : Web向け8フィールド
MobileClient --> MobileBff : Mobile向け5フィールド+recs
WebBff --> ApiGateway : upstream
MobileBff --> ApiGateway : upstream
ApiGateway --> ProductService
MobileBff --> RecommendationService
WebClient と MobileClient はそれぞれ専用のBFFを持ち、それぞれが必要な形のデータを受け取ります。RecommendationService の追加呼び出しも MobileBff の中に収まっています。
制服が二着になった
テストで確認した。
| |
全部 ok だった。
「Web には必要なフィールドだけ。Mobile には必要なフィールドだけ、プラス関連商品」と声に出してみた。
「それぞれの玄関ができた」ロックさんが言った。
「前回の Gateway は……正しかったんですね」
「そうだよ。前回は被害半径を止めた。今回は形を分けた。どちらが欠けても、今日の答えには辿り着けなかった」
(設計に最後の一手はない。前回の答えが今回の土台になった——今回の答えが、次の土台になる。それが設計なのかもしれない)
ラップトップを閉じて、立ち上がった。
ロックさんが声をかけてきたのは、帰ろうとしたときだった。
「待ちたまえ、ワトソン君」
振り向いた。ロックさんは窓の方を向いたままだった。
「一つ聞いてもよいかね。おまえは今回、自分で仮説を持ってきたかね」
「……『BFF』というものかもしれないと、来る前に少し調べました」
「よろしい」
間があった。
「今日が私の最後の依頼だ」
「え——それは、どういう意味ですか」
「そのままの意味だよ」
何を言うべきか分からなかった。引き止めるのも変だし、理由を聞いても答えないと思った。2回の訪問で、そういう人だと分かっていた。
「わかりました。——ありがとうございました、ロックさん」
ロックさんは何も言わなかった。それでいいと思った。
階段を下りて、外に出た。振り返ったとき、LCI の看板の横に、小さな「準備中」のカードが下がっていた。いつからそこにあったのか、分からなかった。
あれから事務所へは行っていない。田中さんに聞いたら、「LCIはもう閉まっている」とだけ言われた。理由は聞かなかった。——次の事件は、自分で解いた。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| One-size-fits-all API | Backends for Frontends(BFF) | WebとMobileがそれぞれ必要なフィールドだけを受け取れる |
| Over-fetching(不要フィールドの受信) | WebBff / MobileBff によるレスポンスの整形 | Mobileが不要な5フィールドを受け取らなくなる |
| Under-fetching(Chatty I/O) | MobileBff への RecommendationService 統合 | Mobileが関連商品を1リクエストで取得できる |
| 共通APIによるチーム間の摩擦 | クライアント種別ごとのBFF所有 | WebチームはWebBffを、MobileチームはMobileBffを独立して管理できる |
推理のステップ
- 全クライアントが同一エンドポイントを呼んでいる場合、各クライアントが実際に使うフィールドを並べて比較する
- Over-fetching(受け取るが使わないフィールド)と Under-fetching(必要だが取れないデータ)を同時に確認する
BFF::Role::Builder(または同等の契約)を定め、各BFFが守るべき共通インターフェースを先に決めるWebBffとMobileBffを分けて実装し、それぞれが自クライアントの形に合わせたレスポンスを組み立てる- 認証・ルーティングなど共通処理は既存の Gateway に残し、BFFはレスポンス整形に集中させる
- テストで「自分のクライアントに不要なフィールドを返さないこと」「必要なデータが1リクエストで取れること」を確認する
BFFが肥大化するリスクには注意が必要です。複数のクライアントを1つのBFFで担当すると、汎用APIと同じ問題が再発します。「1クライアント種別 = 1BFF」を原則とし、ビジネスロジックはBFFに持ち込まず、下流サービスへ委譲してください。
Gateway との関係も重要です。BFFはGatewayを置き換えるのではなく、後ろ側でデータを整える役割を担います。認証・ルーティングはGatewayに残し、BFFは整形に徹することで、それぞれが薄く保てます。
ロックより
「一つの答えで全員を満足させようとするな。全員に合う答えは、結局誰にも届かない。相手の形を知り、相手の形に合わせて整えること——それが、すべての設計の出発点だよ。統一は問題を解く。分化は問題に応える。おまえはそれを二回で学んだ。よろしい。——それぞれの玄関を作りたまえ、ワトソン君。次は自分で建てることだ」
