プログラマのための IT 教科書

API を設計する

この部の 2 / 13 章 ・ 全体で 44 / 76 章 ・ 読了目安 45 分

この章を読むとできるようになること
  • 呼ぶ側が困らない API を設計できる
  • エラーを呼び出し側が分岐できる形で返せる
  • 後から変更できる余地を残して設計できる

前章で .proto の書き方を覚えました。文法が分かれば API は書けます。 しかし「書ける」と「設計できる」は別のことです。

API は一度公開すると、もう自分の都合では変えられません。 この章では、後から直せなくなる前に決めておくべきことを扱います。

API を変えられないのはなぜか

自分のサービスの中の関数なら、名前が気に入らなければ今すぐ変えられます。 IDE のリネームを実行して、テストを通して、デプロイして終わりです。

API は違います。

   あなたのサービス(v1 を公開)
        ▲    ▲    ▲
        │    │    │
     注文    通知   管理画面     ← 3つの別チームが既に呼んでいる

フィールドを1つ消すと、次のことが起きます。

起きること誰が困るか
呼び出し側のコードがコンパイルできなくなる3チーム全員
修正・レビュー・デプロイの調整が必要になる3チーム全員
デプロイの順序を間違えると本番が壊れるあなた

あなたの1行の修正が、他チームの1週間になります。 だから API の設計は、実装を始める前に時間をかける価値があります。

内部実装はいくらでも変えていい

誤解しないでください。変えられないのは公開した「境界」だけです。

.proto に書いた RPC 名・メッセージ・フィールド番号は変えられませんが、 その裏側の DB スキーマ、キャッシュ、アルゴリズムは自由に変えられます。

良い API 設計とは、内部を自由に変え続けられる境界を引くことでもあります。 内部構造をそのまま外に出すと、内部を変えるたびに API が壊れます。

呼ぶ側の都合で設計する

新人が最初にやってしまう設計は、自分の DB のテーブルをそのまま外に出すことです。

// 悪い例: 内部のテーブル構造がそのまま出ている
service UserService {
  rpc SelectUserRow(SelectUserRowRequest) returns (UserRow);
  rpc SelectUserProfileRow(SelectUserProfileRowRequest) returns (UserProfileRow);
  rpc SelectUserSettingRow(SelectUserSettingRowRequest) returns (UserSettingRow);
}

これは動きます。実装も楽です。しかし次の問題があります。

  • 呼ぶ側はあなたのテーブル設計を知らないと使えません
  • ユーザー1人を表示するのに3回呼ぶ必要があります
  • テーブルを3つに分けるのをやめた瞬間、API が壊れます
// 良い例: 呼ぶ側が欲しい単位で切る
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
 
message GetUserRequest {
  string user_id = 1;
}
 
message GetUserResponse {
  User user = 1;
}

テーブルが3つでも5つでも、呼ぶ側には関係ありません。 API の形は、データの保存方法ではなく、使われ方から決めます。

設計を始める時の最初の問いは1つです。

この API を最初に呼ぶのは誰で、その人は何をしたいのか。

答えられないなら、まだ設計を始めるには早いです。

RPC の粒度

粒度は、設計で最も判断を迫られる論点です。細かすぎても粗すぎても失敗します。

細かすぎる — N+1 の API 版

「ユーザー一覧を表示し、それぞれのフォロワー数を出す」画面を考えます。

// 悪い例: 細かすぎる
rpc ListUserIds(ListUserIdsRequest) returns (ListUserIdsResponse);
rpc GetUserName(GetUserNameRequest) returns (GetUserNameResponse);
rpc GetFollowerCount(GetFollowerCountRequest) returns (GetFollowerCountResponse);

50人表示するには 1 + 50 + 50 = 101回の呼び出しが必要です。 データベース章で出てくる N+1 問題と、まったく同じ構造がネットワーク越しに起きます。 しかも1回あたりのネットワーク往復は、DB クエリよりずっと高くつきます。

// 良い例: まとめて取れる
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
 
message ListUsersResponse {
  repeated UserSummary users = 1;   // 名前もフォロワー数も入っている
  string next_page_token = 2;
}

粗すぎる — 使わないデータまで返る

逆に、何でも返す巨大な RPC も失敗します。

// 悪い例: 粗すぎる
message GetUserResponse {
  User user = 1;
  repeated Post all_posts = 2;         // 1万件あるかもしれない
  repeated User all_followers = 3;     // 10万件あるかもしれない
  PaymentHistory payment_history = 4;  // 名前を出したいだけの画面にも渡る
}
  • 名前だけ欲しい画面でも、全部の計算が走ります
  • 支払い履歴が要らない呼び出し側にも個人情報が渡ります
  • 遅い部分(フォロワー全件)が、速い部分の足を引っ張ります

判断の基準

症状粒度の問題直し方
ループの中で RPC を呼んでいる細かすぎる複数件をまとめて返す RPC を足す(BatchGetUsers)
1画面のために5回以上呼んでいる細かすぎるユースケース単位の RPC を検討する
レスポンスの半分以上を捨てている粗すぎる用途ごとに RPC を分ける
「重い項目があるので遅い」と言われる粗すぎる重い項目を別 RPC に切り出す

画面やユースケースを1つ思い浮かべて、それが何回の呼び出しで完成するかを数えてください。 1〜2回で終わるのが理想です。

ユースケース単位の RPC はどこに置くか

「画面単位で作る」と言われると、ドメインのサービスに GetUserProfilePageForMobileApp のような RPC が生えていきます。これは後で困ります。

一般的な分け方はこうです。

  • ドメインサービス — 責務単位で切る(UserService, PostService)。画面を知らない
  • BFF / ゲートウェイ — 画面単位で組み立てる。複数のドメインサービスを呼んで1つにまとめる

画面は頻繁に変わり、ドメインはあまり変わりません。 変わる速度が違うものを、同じ層に混ぜないでください。

命名 — 一貫性が最優先

次の API を見てください。

// 悪い例: 動詞がバラバラ
service UserService {
  rpc FetchUser(FetchUserRequest) returns (User);
  rpc GetUserList(GetUserListRequest) returns (UserListResponse);
  rpc UserCreate(UserCreateRequest) returns (UserCreateResponse);
  rpc ModifyUser(ModifyUserRequest) returns (User);
  rpc RemoveUserById(RemoveUserByIdRequest) returns (RemoveUserByIdResponse);
}

個々の名前は、それ単体では間違っていません。問題は混ざっていることです。 使う側は毎回エディタの補完を眺めて「取得は Fetch だったか Get だったか」を確認することになります。

// 良い例: 5つの標準動詞に揃える
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
  rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
}
操作動詞対象補足
1件取得Get単数形GetUser
複数取得List複数形ListUsers。ページネーションを付ける
ID 指定で複数取得BatchGet複数形BatchGetUsers
作成Create単数形CreateUser
更新Update単数形UpdateUser。field_mask を付ける
削除Delete単数形DeleteUser

5つに収まらない操作

現実には「注文をキャンセルする」「記事を公開する」のような操作があります。 これを無理に Update に押し込めると、意図が消えます。

// 悪い例: 何をしているのか読めない
rpc UpdateOrder(UpdateOrderRequest) returns (UpdateOrderResponse);
// 呼ぶ側: status を "CANCELED" に変える…でいいんだよね?
 
// 良い例: 操作を名前にする
rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse);
rpc PublishArticle(PublishArticleRequest) returns (PublishArticleResponse);

専用の RPC にすると、次が設計できるようになります。

  • 権限 — キャンセルだけ別の権限にできる
  • バリデーション — 「発送済みはキャンセル不可」をサーバー側で保証できる
  • 監査ログ — 誰がいつキャンセルしたかを残せる
  • エラー — 「既にキャンセル済み」を明確に返せる

Update で status を書き換えさせると、この4つすべてが呼ぶ側任せになります。

迷ったら既存の API に合わせる

自分が「より良い」と思う命名規則を1人だけ持ち込むのは、最も避けるべきことです。

社内に既に100個の RPC があり、それが Get / List で揃っているなら、 あなたが Fetch の方が正確だと考えていても Get を使ってください。

API の使いやすさは、個々の名前の正しさより、全体の予測可能性で決まります。 「たぶんこういう名前だろう」で当たる API が良い API です。

リクエストとレスポンスは必ず専用メッセージにする

一見冗長に見えるルールですが、これは将来の変更余地を買うためのものです。

// 悪い例: 引数を直接渡し、ドメイン型を直接返す
rpc GetUser(google.protobuf.StringValue) returns (User);

半年後、次の要望が来ます。

  • 「取得時に、自分がフォローしているかも一緒に返してほしい」
  • 「言語を指定して、その言語の表示名を返してほしい」

引数が StringValue ではフィールドを足す場所がありません。 User を直接返していると、is_following を User に足すことになりますが、 これは「ユーザーそのものの属性」ではないので、他の RPC でも意味不明な値が付いて回ります。

// 良い例: 専用の Request / Response を挟む
message GetUserRequest {
  string user_id = 1;
  string language_code = 2;   // 後から足せる
}
 
message GetUserResponse {
  User user = 1;
  bool is_following = 2;      // 「この呼び出しの文脈」の情報はここに置く
}

ルールは単純です。

  • すべての RPC に XxxRequest / XxxResponse を作る
  • フィールドが1つでも、空でも作る
  • ドメインのメッセージ(User)はレスポンスの中に入れる。直接返さない

空の Response も同じ理由で作ります。google.protobuf.Empty を返すと、 後から「削除件数を返したい」と思っても足せません。

ページネーション

一覧を返す RPC には、必ずページネーションを入れます。 「今はデータが100件だから全部返す」という API は、データが増えた日に落ちます。

offset 方式の問題

// 悪い例
message ListUsersRequest {
  int32 offset = 1;
  int32 limit = 2;
}

SQL の LIMIT 20 OFFSET 40 にそのまま対応するので、実装は簡単です。 問題は2つあります。

1. 途中で挿入・削除されるとズレます。

時刻 T1: 1ページ目を取得(offset=0, limit=3)
   [A, B, C]  ← 取得できた

時刻 T2: 誰かが新しいユーザー X を先頭に追加
   [X, A, B, C, D, E]

時刻 T3: 2ページ目を取得(offset=3, limit=3)
   [C, D, E]  ← C が2回出てくる。取りこぼしも同じ理屈で起きる

2. 深いページが遅くなります。

OFFSET 100000 は「10万件読んで捨てる」という意味です。 INDEX が効いていても、捨てる分の走査は発生します。 ページが深くなるほど線形に遅くなり、ある日タイムアウトします。

cursor 方式

// 良い例
message ListUsersRequest {
  int32 page_size = 1;      // 1ページの件数(上限はサーバーが決める)
  string page_token = 2;    // 前回のレスポンスの next_page_token をそのまま渡す
  string order_by = 3;      // "create_time desc"
  string filter = 4;        // "status = ACTIVE"
}
 
message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;   // 空文字なら、これが最後のページ
}

サーバー側は「前回の最後の要素の位置」をトークンに詰めて返し、 次回は WHERE (create_time, id) < (前回の値) のように続きから読みます。 何件目かではなく、どこまで読んだかを持ち歩くので、挿入されてもズレず、深いページも遅くなりません。

観点offset 方式cursor 方式
途中で挿入された時重複・取りこぼしが起きる起きない
深いページの性能ページが深いほど遅い一定
「5ページ目に飛ぶ」できるできない(順に辿る)
総件数の表示出しやすい別途数える必要がある
実装の手間少ない中くらい

「5ページ目に飛べない」は本当の制約です。 管理画面のようにページ番号での移動が要件なら offset 方式を選ぶ、 無限スクロールや同期処理なら cursor 方式、という判断になります。 判断の理由を .proto のコメントに1行書いておいてください。

page_token の中身を呼ぶ側に見せない

トークンは不透明な文字列として扱わせます。 「実は base64 された offset です」と説明してしまうと、 呼ぶ側が自分で組み立て始め、内部実装を変えられなくなります。

もう2つ、実装時に必ず決めることがあります。

  • page_size の上限。決めていないと、誰かが page_size = 1000000 を投げてサービスが落ちます。「上限100、未指定なら20」のように決めて、超えた値は上限に丸めます
  • ソート順を一意にする。create_time だけで並べると同時刻のレコードで順序が不定になり、カーソルがズレます。(create_time, id) のように必ず一意になる組で並べます

管理画面の一覧 API を offset / limit で作りました。件数が増えるにつれ「最後のページを開くと 504 になる」という報告が来ました。原因として最も可能性が高いのは?

部分更新 — 全体更新は他人の変更を消す

更新 API で最もよくある事故がこれです。

// 悪い例: User をまるごと受け取って上書きする
rpc UpdateUser(User) returns (User);

呼ぶ側は「読んで、直して、書く」をします。

10:00:00  画面A が GetUser →  { name: "佐藤", email: "a@example.com" }
10:00:05  画面B が GetUser →  { name: "佐藤", email: "a@example.com" }
10:00:10  画面B が email を変更して UpdateUser → email が "b@example.com" になる
10:00:30  画面A が name だけ変更して UpdateUser
          → 手元の古い email "a@example.com" ごと送るので、画面B の変更が消える

画面A は email を触っていないのに、email を巻き戻しました。 これは失われた更新(ロストアップデート)と呼ばれ、エラーも出ずに起きます。

// 良い例: 更新するフィールドを明示させる
import "google/protobuf/field_mask.proto";
 
message UpdateUserRequest {
  User user = 1;
  google.protobuf.FieldMask update_mask = 2;  // "display_name" だけ更新する
  string etag = 3;                            // 取得時の版と違えば失敗させる
}
  • update_mask — マスクに含まれるフィールドだけを更新します。含まれないフィールドは、たとえリクエストに値が入っていても無視します
  • etag — 取得時に受け取った版を送り返させ、一致しなければ ABORTED を返します。「読んだ時から変わっていたら失敗する」を実現します

前章で扱った proto3 のゼロ値問題(0 が「未指定」なのか「0という値」なのか区別できない)も、 update_mask があれば起きません。「送られていないから更新しない」ではなく「マスクにあるから更新する」と判断するためです。

update_mask を無視した実装をしない

.proto に update_mask を書いておきながら、 サーバー実装が中身を見ずに全フィールド上書きしている、という状態が実際にあります。

これは最悪です。呼ぶ側は保護されているつもりで使うので、 事故が起きるまで誰も気づきません。

.proto に書いた約束は、実装で守られて初めて意味を持ちます。 レビューでは .proto と実装の両方を見てください。

エラー設計 — 呼ぶ側が分岐できる形で返す

エラーは「返せば終わり」ではありません。 呼ぶ側がそのエラーを見て、次に何をするかを決められる必要があります。

// 悪い例: 成功も失敗も OK で返す
message CreateOrderResponse {
  bool success = 1;
  string message = 2;   // "在庫が足りません(SKU-123、残り2個)"
}

何が悪いのでしょうか。

問題結果
呼ぶ側は message を文字列で判定するしかない文言を直した瞬間、呼ぶ側が壊れる
gRPC のステータスは OK のまま監視のエラー率に出ない。障害に気づけない
リトライしてよいか分からない呼ぶ側が判断できず、無条件リトライか諦めるかの二択になる
呼ぶ側の言語で表示できないサーバーが返した日本語をそのまま出すことになる

正しくは、ステータスコードで分類し、詳細は構造化して返します。

// サーバー側(Go)
st := status.New(codes.FailedPrecondition, "insufficient stock")
st, _ = st.WithDetails(&errdetails.ErrorInfo{
    Reason:   "OUT_OF_STOCK",              // 安定した識別子。文言ではない
    Domain:   "shop.example.com",
    Metadata: map[string]string{"sku": "SKU-123", "available": "2"},
})
return nil, st.Err()
// 呼ぶ側は「コード」と「reason」で分岐できる
if status.Code(err) == codes.FailedPrecondition {
    // 在庫を確認する画面に誘導する、など
}

Reason は列挙できる安定した文字列にします。 表示用の文言は呼ぶ側が持ち、サーバーは「何が起きたか」だけを返す、という分担です。

ステータスコードの選び方

前章でコードと HTTP の対応は見ました。ここではどれを選ぶかを整理します。

コード選ぶ条件リトライ呼ぶ側がすること
INVALID_ARGUMENT引数だけを見て間違いと分かる(形式不正、必須欠け)無意味リクエストを直す
FAILED_PRECONDITION引数は正しいがシステムの状態が条件を満たさない(在庫切れ、発送済みでキャンセル不可)状態を直すまで無意味状態を直してから再実行
NOT_FOUND指定されたリソースが存在しない無意味ID を見直す
ALREADY_EXISTS作ろうとしたものが既にある無意味既存を使うか、更新に切り替える
PERMISSION_DENIED認証は通っているが権限がない無意味権限を申請する
UNAUTHENTICATEDそもそも認証されていないトークン更新後なら可再ログイン・トークン更新
RESOURCE_EXHAUSTEDレート制限・クォータ超過待てば可バックオフして再実行
ABORTED競合(トランザクション衝突、etag 不一致)可読み直してから再実行
UNAVAILABLE一時的に応答できない可バックオフして再実行
DEADLINE_EXCEEDED時間切れ条件付きで可冪等なら再実行
INTERNAL想定外の例外。バグ無意味報告する。サーバー側のログを見る

新人が特に迷うのはこの3つの区別です。

  • INVALID_ARGUMENT と FAILED_PRECONDITION — 「同じリクエストを後で送れば成功しうるか」で分けます。成功しうるなら FAILED_PRECONDITION です
  • FAILED_PRECONDITION と ABORTED — FAILED_PRECONDITION は「状態を直すまで再試行するな」、ABORTED は「そのまま再試行してよい」です
  • INTERNAL を安易に使わない — INTERNAL は「サーバーのバグ」を意味します。バリデーション漏れを全部 INTERNAL で返すと、監視上はすべてバグに見え、本物の障害が埋もれます
エラーメッセージに機密情報を入れない

エラーメッセージは、呼ぶ側のログにも、ブラウザのコンソールにも、監視ツールにも残ります。 そのすべてを、あなたは管理できません。

入れてはいけないもの。

  • SQL 文、スタックトレース、内部のホスト名やファイルパス
  • 他人の個人情報(user 42(tanaka@example.com)は権限がありません)
  • 認証情報、トークン、秘密鍵の一部

代わりに trace_id を返し、詳細はサーバー側の構造化ログに書きます。 「この ID で調べてください」が最も安全で、調査も速くなります。

存在の秘匿にも注意します。他人のリソースに PERMISSION_DENIED を返すと、 「その ID は存在する」という情報が漏れます。 秘匿したい場合は NOT_FOUND を返す、という判断をチームで決めておいてください。

リトライできるかどうかを設計に含める

ネットワークは失敗します。呼ぶ側は必ずリトライを検討します。 ならば、リトライしてよいかどうかは、API の設計時に決めておくべきことです。

Get や List は自然に冪等なので何も要りません。問題は作成と更新です。

// 悪い例: 再送すると注文が2件できる
message CreateOrderRequest {
  Order order = 1;
}
 
// 良い例: 呼ぶ側が生成した ID で重複を防ぐ
message CreateOrderRequest {
  Order order = 1;
  string request_id = 2;  // クライアント生成の UUID。同じ ID の再送は1回だけ処理する
}

サーバーは request_id と処理結果を保存しておき、 同じ request_id が来たら処理せずに前回の結果を返します。 「応答が届かなかっただけで、実は成功していた」という最も厄介なケースを、これで吸収できます。

各 RPC について、次を決めて .proto のコメントに書きます。

RPC の種類冪等か設計
Get / Listはい何も不要
Createいいえrequest_id を持たせる
Update(全体を置き換え)はいetag で競合を検出する
Delete設計次第2回目をどうするか決める

Delete は特に決めておく価値があります。 2回目に NOT_FOUND を返すと、リトライした呼び出し側がエラー扱いします。 allow_missing フィールドを用意して「無ければ成功扱い」を選べるようにするのが一般的です。

タイムアウトやバックオフの具体的なやり方、リトライストームの怖さは、 第5部のマイクロサービス章で扱います。ここで押さえるのは1点です。

リトライ可能な API にするかどうかは、呼ぶ側ではなく、設計する側が決める。

バージョニング

パッケージにバージョンを入れます。

syntax = "proto3";
 
package shop.user.v1;
 
option go_package = "github.com/example/shop/gen/user/v1;userv1";

v1 は「1つ目のメジャーバージョン」であり、壊れる変更をする時にだけ上げるものです。 機能追加のたびに上げるものではありません。

まず、壊さずに済ませる

v2 を作るのは最後の手段です。ほとんどの変更は壊さずにできます。

やりたいこと壊さない方法
フィールドを増やす新しい番号で追加する(前章の通り安全)
フィールドを消すまず deprecated を付け、使われなくなってから reserved にする
意味を変えたい変えずに、新しいフィールドを足して旧フィールドを非推奨にする
型を変えたい新しい番号・新しい型のフィールドを足し、両方を書き続ける期間を設ける
引数を増やしたいRequest メッセージに足す(専用メッセージにしてある理由がこれです)
enum には必ず UNSPECIFIED = 0 を置く
enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;   // 必ず 0 番に置く
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_SHIPPED = 2;
}

proto3 では未設定と 0 が区別できないため、0 番に意味のある値を置くと 「送られていない」と「PENDING」が同じになります。

そしてもう1つ。enum に値を足すのは後方互換ですが、古いクライアントはその値を知りません。 SHIPPED の次に CANCELED = 3 を足すと、古いクライアントは 3 を未知の値として受け取ります。

呼ぶ側は switch に必ず default の分岐を書いてください。 知らない値が来た時に落ちるコードは、サーバーが1つ値を足しただけで壊れます。

どうしても壊れる変更が必要な時

パッケージごと v2 を作って並走させます。 ただし、その前に「新しい RPC を足す」で済まないかを検討してください。

service UserService {
  // 2026-12-31 に削除予定。GetUserProfile を使うこと
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option deprecated = true;
  }
 
  rpc GetUserProfile(GetUserProfileRequest) returns (GetUserProfileResponse);
}

移行の手順は決まっています。

  1. 新しい RPC を追加し、古い RPC に deprecated と削除予定日を書く
  2. 古い RPC の呼び出しに、呼び出し元ごとのラベルを付けたメトリクスを仕込む
  3. 呼んでいるチームに連絡する(メトリクスがあるので、誰に連絡すべきか分かります)
  4. 呼び出し数がゼロの状態が数週間続いてから消す
「誰も使っていないはず」で消さない

削除の判断を記憶や grep でやってはいけません。 社内の別リポジトリ、バッチ、古いモバイルアプリ、外部パートナー—— grep できない呼び出し元は必ずいます。

「使われていないこと」を消す前にメトリクスで証明してください。 これができない API は、実質的に消せません。だから最初の設計が重要なのです。

時刻・金額・ID の扱い

型の選択だけで防げる事故があります。

// 悪い例
message Order {
  string created_at = 1;   // "2026/07/31 10:00" タイムゾーンは? 形式は?
  double amount = 2;       // 浮動小数点で金額を扱っている
  int64 id = 3;            // 連番の ID
  string expires_in = 4;   // "30分" なのか "30" なのか
}
// 良い例
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/type/money.proto";
 
message Order {
  string id = 1;                                // 不透明な文字列(UUID など)
  google.protobuf.Timestamp create_time = 2;    // 絶対時刻。曖昧さがない
  google.type.Money amount = 3;                 // 通貨コード + 整数
  google.protobuf.Duration expires_after = 4;   // 期間は Duration
}

時刻

  • 文字列にしない。 形式の解釈違い、タイムゾーンの欠落、パース失敗が必ず起きます
  • Timestamp は UTC の絶対時刻です。表示のためのタイムゾーン変換は表示側の仕事です
  • 「時刻」は Timestamp、「期間」は Duration、「日付だけ」(誕生日、締日)は google.type.Date を使い分けます
  • フィールド名は create_time / update_time のように揃えます。created_at と create_time が混在する API は読みにくくなります

金額

浮動小数点(double / float)で金額を扱ってはいけません。

第3部のビットとバイト章で見た通り、0.1 は二進数で正確に表せません。 足し込むたびに誤差が積もり、帳簿が合わなくなります。合計金額が1円ずれる障害は、原因の特定に何日もかかります。

選択肢は2つです。

  • google.type.Money(currency_code + units + nanos)を使う
  • 最小単位の整数(円なら円、ドルならセント)と通貨コードを自分で持つ

どちらでも構いませんが、通貨コードを必ずセットで持ってください。 amount: 1000 だけでは、1000円なのか1000ドルなのか分かりません。

ID

ID に意味を持たせないでください。

悪い例: "2026-07-tokyo-0001"

一見便利ですが、次の日に破綻します。

  • 拠点が統合されたら? 年をまたいだら? 桁が足りなくなったら?
  • 呼ぶ側がID をパースし始めます。そうなった時点で、ID の形式は API の一部です。二度と変えられません

連番の int64 にも問題があります。

  • 件数が漏れます(ID が 4213 なら、だいたい4千件だと分かります)
  • 他人のデータを推測できます(id - 1 にアクセスしてみる、という攻撃が成立します)
  • データベース章で扱う通り、分散データベースでは書き込みが1箇所に集中します

外部に出す ID は、UUID などの推測できない文字列にしてください。

レビューで「この API、amount を double にしているけど大丈夫?」と指摘されました。何が問題ですか。

REST でも同じ問題が起きる

ここまで gRPC で書いてきましたが、設計の論点は REST でも同じです。 表現の形が変わるだけで、考えることは変わりません。

設計の論点gRPCREST
命名GetUser / ListUsersGET /users/42 / GET /users
リクエストの拡張余地専用の Request メッセージクエリパラメータ・ボディ
ページネーションpage_token / next_page_token?page_token= とレスポンスの next
部分更新UpdateXxx + field_maskPATCH + マージパッチ
エラーの分岐ステータスコード + ErrorInfoHTTP ステータス + エラーボディの code
冪等キーrequest_id フィールドIdempotency-Key ヘッダ
バージョンpackage shop.v1/v1/users

REST そのものの考え方(リソースとメソッド)は第3部の Web と HTTP 章で扱っています。 「呼ぶ側が分岐できるエラーを返す」「後から足せる形にする」という原則は、プロトコルに依存しません。

設計レビューを先に受ける

この章の結論です。.proto のレビューは、実装を1行も書く前に受けてください。

実務では、次の順序で進めます。

1. 何を作るか・誰が呼ぶかを文章で書く(1ページで足りる)
2. .proto だけの PR を出す        ← ここでレビューを受ける
3. 承認されてから実装する
4. 実装の PR を出す

.proto だけの PR は差分が小さく、レビューする側も本質だけを見られます。 実装まで書いてから「この粒度だと使いにくい」と言われると、 指摘した側も気を遣い、直すべきものが直らないまま公開されます。

レビューで見るべきこと。

  • 呼ぶ側のユースケースが書いてあるか。 誰が何のために呼ぶか
  • その画面・処理は何回の呼び出しで完成するか
  • 命名が既存の API と一貫しているか(Get / List / Create / Update / Delete)
  • すべての RPC に専用の Request / Response があるか
  • 一覧にページネーションがあるか。page_size の上限は決めたか
  • 更新に field_mask と競合検出があるか
  • 返しうるエラーが列挙されているか(コードと条件)
  • リトライ可否と冪等性が決まっているか
  • 時刻は Timestamp、金額は整数系、ID は不透明か
  • 返しているデータに、呼ぶ側が持つべきでない個人情報がないか
使う側のコードを先に書いてみる

設計に迷ったら、その API を呼ぶ側のコードを想像で書いてみてください。

const { user } = await client.getUser({ userId });
const { posts, nextPageToken } = await client.listPosts({ authorId: userId, pageSize: 20 });

書いてみると、粒度と命名の問題はすぐ見えます。 ループが必要、変数の詰め替えが多い、同じ情報を2回取っている—— それが使いにくさの正体です。

レビュー依頼にこの数行を添えると、議論が一気に具体的になります。

実務の落とし穴まとめ

  1. 内部のテーブル構造をそのまま公開する — 内部を変えるたびに API が壊れる
  2. ループの中で RPC を呼ぶ設計 — N+1 の API 版。まとめて返す RPC を用意する
  3. 動詞がバラバラ — Get / Fetch / Find の混在。使う側が毎回迷う
  4. スカラー引数・ドメイン型を直接やり取り — 後からフィールドを足せない
  5. offset ページネーション — 挿入でズレ、深いページで落ちる
  6. page_size の上限を決めていない — 巨大な値を投げられて落ちる
  7. 全体更新 API — 他人の変更を消す。field_mask と etag を使う
  8. success: bool + メッセージ文字列でエラーを返す — 呼ぶ側が分岐できず、監視にも出ない
  9. エラーメッセージに内部情報や個人情報 — 呼ぶ側のログに残り、回収できない
  10. 金額を double、時刻を文字列、ID を連番 — 型の選択だけで防げた事故
  11. 実装してからレビューを出す — もう直せない

まとめ

  • API は一度公開すると変えられない。変えられないのは境界だけで、内部は自由に変えてよい
  • 設計の起点は呼ぶ側のユースケース。DB のテーブル構造ではない
  • 粒度は画面・ユースケース単位で数える。細かすぎれば N+1、粗すぎれば無駄と情報漏れ
  • 命名は一貫性が最優先。Get / List / Create / Update / Delete に揃え、状態遷移は専用 RPC
  • すべての RPC に専用の Request / Response を作る。後からフィールドを足すため
  • 一覧は cursor 方式を基本に。page_size の上限とソート順の一意性を決める
  • 更新は field_mask + etag。全体更新は他人の変更を消す
  • エラーは呼ぶ側が分岐できる形で返す。コードで分類し、詳細は構造化する
  • エラーメッセージに機密情報を入れない。trace_id を返してログで追う
  • リトライ可否と冪等性は設計時に決める。Create には request_id
  • 破壊的変更は避ける。どうしても必要なら新しい RPC を足して並走させ、メトリクスで移行を確認してから消す
  • 時刻は Timestamp、金額は整数系、ID には意味を持たせない
  • .proto だけの PR で先にレビューを受ける。実装してからでは直せない

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
Google API 設計ガイド(日本語)https://cloud.google.com/apis/design?hl=ja
OpenAPI 仕様https://spec.openapis.org/oas/latest.html
MDN: HTTP メソッドhttps://developer.mozilla.org/ja/docs/Web/HTTP/Methods
RFC 9457(エラーレスポンスの形式)https://www.rfc-editor.org/rfc/rfc9457.html

章末問題

注文作成 API で、在庫が足りずに作成できませんでした。どのステータスコードを返すべきですか。

公開済みの GetUserResponse に、新しく is_verified フィールドを追加したいと言われました。どう進めますか。

呼ぶ側から「Create を呼んだがタイムアウトした。リトライしていいか」と質問されました。設計者として最も適切な対応は?

次の章では、この API の裏側でデータを保存する場所——データベースを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。