Skip to content

VOL 07 / CH 02 / LESSON 01

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.

proto
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 UNSPECIFIED enum 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.
proto
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 ​

PatternRequestResponseSuitable For
Unary11Ordinary queries and commands
Server streaming1MultipleSubscriptions, incremental updates
Client streamingMultiple1Batch uploads, aggregation
Bidirectional streamingMultipleMultipleBidirectional 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 ​

DimensionJSON/HTTPgRPC/Protobuf
Human debuggingDirectSpecialized tools with server reflection or a local schema
Browser and public ecosystem supportMatureOften requires proxies or web support
Strong IDL and code generationAvailable via OpenAPINative workflow maturity
Streaming RPCAchievable but with varied formsFour well-defined forms
Binary efficiencyDepends on JSON and compressionTypically more compact
Versioning riskField and semantic changesField numbers, schema, and semantics

Next lesson covers the runtime semantics of RPC: deadlines, cancellation, retries, and backpressure.

References ​

Built with VitePress | Software Systems Atlas