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回で終わるのが理想です。
「画面単位で作る」と言われると、ドメインのサービスに
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つすべてが呼ぶ側任せになります。
自分が「より良い」と思う命名規則を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行書いておいてください。
トークンは不透明な文字列として扱わせます。 「実は 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 があれば起きません。「送られていないから更新しない」ではなく「マスクにあるから更新する」と判断するためです。
.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 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);
}移行の手順は決まっています。
- 新しい RPC を追加し、古い RPC に
deprecatedと削除予定日を書く - 古い RPC の呼び出しに、呼び出し元ごとのラベルを付けたメトリクスを仕込む
- 呼んでいるチームに連絡する(メトリクスがあるので、誰に連絡すべきか分かります)
- 呼び出し数がゼロの状態が数週間続いてから消す
削除の判断を記憶や 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 でも同じです。 表現の形が変わるだけで、考えることは変わりません。
| 設計の論点 | gRPC | REST |
|---|---|---|
| 命名 | GetUser / ListUsers | GET /users/42 / GET /users |
| リクエストの拡張余地 | 専用の Request メッセージ | クエリパラメータ・ボディ |
| ページネーション | page_token / next_page_token | ?page_token= とレスポンスの next |
| 部分更新 | UpdateXxx + field_mask | PATCH + マージパッチ |
| エラーの分岐 | ステータスコード + ErrorInfo | HTTP ステータス + エラーボディの 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回取っている—— それが使いにくさの正体です。
レビュー依頼にこの数行を添えると、議論が一気に具体的になります。
実務の落とし穴まとめ
- 内部のテーブル構造をそのまま公開する — 内部を変えるたびに API が壊れる
- ループの中で RPC を呼ぶ設計 — N+1 の API 版。まとめて返す RPC を用意する
- 動詞がバラバラ —
Get/Fetch/Findの混在。使う側が毎回迷う - スカラー引数・ドメイン型を直接やり取り — 後からフィールドを足せない
- offset ページネーション — 挿入でズレ、深いページで落ちる
page_sizeの上限を決めていない — 巨大な値を投げられて落ちる- 全体更新 API — 他人の変更を消す。
field_maskとetagを使う success: bool+ メッセージ文字列でエラーを返す — 呼ぶ側が分岐できず、監視にも出ない- エラーメッセージに内部情報や個人情報 — 呼ぶ側のログに残り、回収できない
- 金額を
double、時刻を文字列、ID を連番 — 型の選択だけで防げた事故 - 実装してからレビューを出す — もう直せない
まとめ
- 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 の裏側でデータを保存する場所——データベースを扱います。