2.1 RPC and Serialization Contracts: Remote Calls Are Not Local Functions
RPC packages network communication with an interface that resembles a local function call. But it cannot inherit the reliability of local calls. Remote calls introduce latency, serialization overhead, independent deployments, version coexistence, and unknown outcomes. The API must expose these differences explicitly.
IDL is a cross-process contract
The following is a complete scoring.proto. The later Player definition can be appended to the same file.
syntax = "proto3";
package atlas.scoring.v1;
service ScoringService {
rpc GetScore(GetScoreRequest) returns (GetScoreResponse);
rpc WatchRanking(WatchRankingRequest) returns (stream RankingUpdate);
}
message GetScoreRequest {
string player_id = 1;
}
message GetScoreResponse {
string player_id = 1;
int64 points = 2;
int64 version = 3;
}
message WatchRankingRequest {
string tournament_id = 1;
optional int64 after_version = 2;
}
message RankingUpdate {
int64 ranking_version = 1;
repeated GetScoreResponse scores = 2;
}Message generators and the gRPC plugin can generate message classes and stubs, but type checks cannot define business meaning. Here, points means integer score points, and GetScoreResponse.version is that player’s score version. ranking_version is the tournament ranking version; the two kinds of version cannot be compared.
Each WatchRanking response is a complete ranking snapshot for the tournament, with strictly increasing versions. Omitting after_version requests the current snapshot followed by updates; supplying it requests only snapshots newer than that version. If the position is outside retention, the service returns FAILED_PRECONDITION, and the client requests a fresh full snapshot. optional distinguishes "no resume position" from "start after version 0." Units, errors, deadlines, idempotency, and compatibility policies need written contracts like these.
Protobuf Encoding Depends on Field Numbers and Wire Types
In the Protobuf binary format, fields are identified by numbers and source field names are not transmitted. ProtoJSON uses field names, so renaming can still break JSON clients. Integer values are typically encoded using varint, while strings and nested messages use length-delimited encoding.
This generally results in compact representations and fast parsing, but it's incorrect to claim fixed compression ratios without considering the payload, language implementation, or compression method. JSON's readability, browser ecosystem, and debugging convenience may still make it more suitable for public APIs.
Schema Evolution Rules
- Published field numbers must not change meaning;
- After removing a field, reserve its number and name;
- Do not change a scalar field to a wire-incompatible or semantically incompatible type;
- Keep an explicit zero-valued
UNSPECIFIEDenum member; - New fields must leave older clients with safe behavior when they ignore them;
- Do not rely on receivers understanding unknown fields for critical business behavior. Binary parsing usually preserves unknown fields, but JSON conversion or field-by-field copying can discard them.
enum PlayerStatus {
PLAYER_STATUS_UNSPECIFIED = 0;
PLAYER_STATUS_ACTIVE = 1;
PLAYER_STATUS_SUSPENDED = 2;
}
message Player {
reserved 4;
reserved "legacy_rank";
string player_id = 1;
string display_name = 2;
PlayerStatus status = 3;
}Adding fields or enum values can also break semantics. An older client might mistakenly interpret an unfamiliar status as permission to participate. Compatibility tests must exercise generated code and business branches. A proto3 scalar without optional usually cannot distinguish absence from an explicitly supplied default value. Whether a missing score should mean zero is a business decision.
Four RPC Patterns
| Pattern | Request | Response | Suitable For |
|---|---|---|---|
| Unary | 1 | 1 | Ordinary queries and commands |
| Server streaming | 1 | Multiple | Subscriptions, incremental updates |
| Client streaming | Multiple | 1 | Batch uploads, aggregation |
| Bidirectional streaming | Multiple | Multiple | Bidirectional independent streams, interactive sessions |
Don't convert all paginated queries into streaming calls just because your framework supports streaming. Streaming RPC requires explicit design of backpressure, cancellation, reconnection on disconnection, message ordering, checkpoints, and maximum message size.
Boundary Models Should Not Reuse Internal Entities
Directly using an ORM Entity as an RPC message couples the database schema, lazy loading, and public contract. Protobuf messages are transport models and should be mapped to domain or application models at the adapter boundary.
Similarly, a single massive Common.proto would force all services to evolve at the same pace. Only stable identifiers and shared protocols should be shared; business models should be published by the context that owns them.
Protocol Selection
| Dimension | JSON/HTTP | gRPC/Protobuf |
|---|---|---|
| Human debugging | Direct | Specialized tools with server reflection or a local schema |
| Browser and public ecosystem support | Mature | Often requires proxies or web support |
| Strong IDL and code generation | Available via OpenAPI | Native workflow maturity |
| Streaming RPC | Achievable but with varied forms | Four well-defined forms |
| Binary efficiency | Depends on JSON and compression | Typically more compact |
| Versioning risk | Field and semantic changes | Field numbers, schema, and semantics |
Next lesson covers the runtime semantics of RPC: deadlines, cancellation, retries, and backpressure.
References
- Protocol Buffers, Language Guide
- gRPC, Core concepts