Skip to content

Designing Methods

A method signature is a promise. Once clients depend on it, you can only change it in backward-compatible ways — or bump the whole package to v2. So the design habits below aren’t polish; they’re what let an API survive years of change without a painful migration.

The single most valuable rule: every method gets its own request message and its own response message. Never take or return a bare scalar, and never reuse a domain message as a request.

// Do this
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
message DeleteUserRequest { int64 id = 1; }
message DeleteUserResponse {} // empty today, has room tomorrow
// Avoid this
rpc DeleteUser(int64) returns (google.protobuf.Empty);

Why the wrappers, even when they’re empty today?

  • Room to grow. Adding a soft_delete flag to the request, or a deleted_at timestamp to the response, is a safe additive change. A bare scalar or shared Empty has nowhere to add it.
  • Clarity. DeleteUserRequest documents intent better than int64.
  • Consistency. Every method looks the same, so tooling and readers never have to special-case anything.

Follow the conventions the code generators expect, and the generated client will read naturally in every language:

  • Methods: VerbNoun in PascalCaseGetUser, CreateOrder, ListInvoices, BatchUpdateItems. Standard verbs (Get, List, Create, Update, Delete) set clear expectations.
  • Messages: MethodNameRequest / MethodNameResponseListUsersRequest, ListUsersResponse.
  • Fields: snake_caseuser_id, page_size, created_at. Generators convert to each language’s casing.

Pagination: never return an unbounded list

Section titled “Pagination: never return an unbounded list”

A ListUsers that returns all users will eventually return millions and fall over. Design list methods to page from the start, using the widely adopted token pattern:

message ListUsersRequest {
int32 page_size = 1; // how many to return
string page_token = 2; // opaque cursor from the previous response
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2; // empty when there are no more
}
flowchart LR
  r1["ListUsers(page_size=50)"] --> p1["50 users +
next_page_token=abc"]
  p1 --> r2["ListUsers(token=abc)"]
  r2 --> p2["next 50 +
next_page_token=def"]
  p2 --> r3["... until token is empty"]
Token-based pagination: each response hands you the key to the next page

The client keeps passing the returned next_page_token back until it comes back empty. The token is opaque — the server decides what it encodes (an offset, a cursor, a keyset), and clients must never parse it. That freedom lets you change the pagination strategy later without touching a single client.

Networks fail, and clients retry. If a retried CreatePayment charges the customer twice, that’s a design bug, not bad luck. Make mutating methods idempotent where you can — commonly with a client-supplied request id the server deduplicates:

message CreatePaymentRequest {
Payment payment = 1;
string request_id = 2; // client-generated; server ignores duplicates
}

The server records each request_id; a repeat returns the original result instead of charging again. Get/List are naturally idempotent; Delete usually is; Create needs help like this.

Why wrap every method in its own request/response message, even empty ones?
What is the recommended casing for protobuf field names?
Why must a page_token be opaque to clients?
How do you commonly make a Create method safe to retry?