gRPC & Protobuf Contract Design
Design service contracts with Protocol Buffers: .proto file design, the four RPC types, field-numbering backward compatibility, versioning, and gRPC vs REST trade-offs.
gRPC & Protobuf Contract Design
REST contracts are JSON over HTTP with conventions you enforce by discipline. gRPC contracts are compiled — the .proto file is the single source of truth, and the compiler generates strongly-typed client and server code in every supported language from it. This shifts a whole category of bugs (typos in field names, wrong types, missing required fields) from runtime to compile time, at the cost of needing a compilation step and losing casual browser/curl debuggability. This guide covers designing .proto contracts, choosing the right RPC shape, and keeping the contract backward-compatible as it evolves.
1. A Full Annotated .proto File
syntax = "proto3";
package orders.v1;
// --- Messages ---
message Order {
int64 id = 1;
int64 customer_id = 2;
OrderStatus status = 3;
repeated LineItem items = 4;
double total_amount = 5;
// oneof: exactly one of these is set — models a discriminated union
oneof discount {
double percentage_discount = 6;
double fixed_discount = 7;
}
}
message LineItem {
int64 product_id = 1;
int32 quantity = 2;
double unit_price = 3;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0; // proto3 enums MUST have a zero value — this is it
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_SHIPPED = 2;
ORDER_STATUS_CANCELLED = 3;
}
message GetOrderRequest { int64 order_id = 1; }
message GetOrderResponse { Order order = 1; }
message CreateOrderRequest { int64 customer_id = 1; repeated LineItem items = 2; }
message CreateOrderResponse { Order order = 1; }
message ListOrdersRequest { int64 customer_id = 1; int32 page_size = 2; string page_token = 3; }
message OrderEvent { Order order = 1; string change_type = 2; } // for server streaming
message OrderUpdate { int64 order_id = 1; OrderStatus new_status = 2; } // for client streaming
message BatchUpdateSummary { int32 updates_applied = 1; }
// --- Service ---
service OrderService {
// Unary: one request, one response — the gRPC equivalent of a REST GET/POST
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
// Server streaming: one request, a stream of responses
rpc WatchOrderEvents(ListOrdersRequest) returns (stream OrderEvent);
// Client streaming: a stream of requests, one response
rpc BatchUpdateStatus(stream OrderUpdate) returns (BatchUpdateSummary);
// Bidirectional streaming: both sides stream independently
rpc SyncOrders(stream OrderUpdate) returns (stream OrderEvent);
}Field numbers (= 1, = 2, ...) — not field names — are what identify a field on the wire. Protobuf serializes to a compact binary format keyed by number, which is why renaming a field is safe (Section 4) but reusing or changing a number is not.
2. The Four RPC Types
Unary RPC
One request in, one response out — functionally equivalent to a REST call.
Server streaming RPC
Client sends one request; server pushes a stream of responses over the same connection until it closes the stream. Good for subscriptions, live feeds, or a large result set delivered incrementally.
Client streaming RPC
Client pushes a stream of messages; server reads them all and returns a single summary response once the client's stream ends. Good for batch uploads or aggregating many small updates into one commit.
Bidirectional streaming RPC
Both sides stream independently over the same long-lived connection — reads and writes interleave freely, in either order, on either side. Good for real-time sync, chat, or collaborative editing.
| RPC type | Request | Response | Typical use |
|---|---|---|---|
| Unary | Single | Single | CRUD-style operations, direct equivalent of REST |
| Server streaming | Single | Stream | Live feeds, subscriptions, large paginated dumps |
| Client streaming | Stream | Single | Batch uploads, telemetry aggregation |
| Bidirectional streaming | Stream | Stream | Chat, real-time collaborative sync, live gaming state |
3. Protobuf Field Numbering and Backward Compatibility
The field number, not the name, is the wire identity of a field. This table is the single most interview-relevant fact about protobuf.
| Change | Safe? | Why |
|---|---|---|
| Add a new field with a new, unused number | Safe | Old readers ignore unknown fields; new readers see it as unset/default on old data |
| Rename a field, keep the same number | Safe | Wire format only encodes the number — the name is compile-time only, doesn't affect serialized bytes |
| Remove a field, but don't reuse its number | Safe (mark as reserved) | Old data with that field is simply ignored by new code |
| Reuse a previously-used field number for a different field | Breaking | Old serialized data will be misinterpreted as the new field's type — silent data corruption |
| Change a field's number | Breaking | Old and new code disagree on what that field's bytes mean |
Change a field's type incompatibly (e.g. int32 to string) | Breaking | Wire encoding differs by type family; old readers misparse |
Change a field's type within the same wire-compatible family (e.g. int32 to int64) | Usually safe | Same wire type (varint); values may just get wider |
Add a new value to an enum | Safe | Old code treats an unrecognized enum value as the zero/unknown value (proto3) |
| Add a new RPC method to a service | Safe | Purely additive — unrelated to existing methods |
Change a field from singular to repeated | Breaking | Wire representation differs |
message Order {
int64 id = 1;
int64 customer_id = 2;
reserved 3; // was `legacy_discount_code` — removed, number retired forever
reserved "legacy_discount_code";
OrderStatus status = 4;
}reserved isn't optional bookkeeping — it's what prevents a future teammate from accidentally reusing field 3 for something unrelated, which would silently corrupt any old serialized Order bytes still in a queue, cache, or at-rest storage that get deserialized by new code.
Versioning gRPC services
| Strategy | Example | When to use |
|---|---|---|
| Package versioning | package orders.v1; → package orders.v2; | Breaking change to the service/message shape — a genuinely new contract |
| Additive evolution within a version | Add optional fields/RPCs to orders.v1 | The default path — most changes should be additive, no version bump needed |
| New RPC method, same message | GetOrderV2(GetOrderRequest) returns (OrderV2Response) | A single endpoint needs a breaking change without bumping the whole service |
Because protobuf changes are additive-by-default and safe (per the table above), gRPC services bump major package versions far less often than REST APIs bump URL versions — most "changes" are just new optional fields, which never require a v2.
4. gRPC vs REST
| gRPC | REST (JSON over HTTP) | |
|---|---|---|
| Wire format | Binary (Protobuf) — compact, fast to (de)serialize | Text (JSON) — human-readable, larger payloads |
| Transport | HTTP/2 — multiplexed streams over one connection | Typically HTTP/1.1, one request per connection round-trip (or HTTP/2 without native streaming semantics) |
| Contract | Strongly typed, compiled .proto — client/server generated in lockstep | Loosely typed, convention-enforced (OpenAPI helps but isn't compiled in) |
| Streaming | Native: unary, server/client/bidi streaming | Not native — needs SSE, WebSockets, or long-polling bolted on |
| Browser support | Needs gRPC-Web + a proxy translation layer; not native fetch/XHR | Native — every browser speaks HTTP/JSON directly |
| Human debuggability | Needs grpcurl or a proxy — binary payload isn't curl-able as-is | curl/Postman/browser devtools work directly |
| Tooling ecosystem | Strong in polyglot microservice backends (Go, Java, C++) | Universal — every language, every platform, every third-party integrator |
| Best fit | Internal service-to-service calls, especially latency-sensitive, high-throughput, or streaming-heavy | Public APIs, browser clients, third-party integrations, anything needing broad compatibility |
A common production pattern: gRPC internally between microservices (fast, typed, streaming-capable), REST/JSON at the edge for browser and public API consumers — bridged with a gRPC-Gateway (Section 6) that auto-generates a REST/JSON facade from the same .proto file, so there's still only one contract to maintain.
5. Error Handling in gRPC
gRPC uses a fixed set of status codes (io.grpc.Status), roughly analogous to but distinct from HTTP status codes — they travel in trailing metadata rather than as an HTTP status line.
| gRPC status | Rough REST equivalent | Meaning |
|---|---|---|
OK | 200 | Success |
INVALID_ARGUMENT | 400 | Malformed request |
UNAUTHENTICATED | 401 | Missing/invalid credentials |
PERMISSION_DENIED | 403 | Authenticated but not allowed |
NOT_FOUND | 404 | Resource doesn't exist |
ALREADY_EXISTS | 409 | Conflict on create |
FAILED_PRECONDITION | 409/422 | State doesn't allow this operation (e.g. cancel a shipped order) |
RESOURCE_EXHAUSTED | 429 | Rate limit or quota exceeded |
INTERNAL | 500 | Unexpected server error |
UNAVAILABLE | 503 | Server temporarily down — safe to retry with backoff |
DEADLINE_EXCEEDED | 504 | Call didn't complete before the deadline |
// Rich error details attach structured data beyond the status code + message,
// via google.rpc.Status and the standard error_details.proto types.
import "google/rpc/error_details.proto";// Server side: returning a rich, typed error
throw Status.FAILED_PRECONDITION
.withDescription("Cannot cancel an order that has already shipped")
.asRuntimeException();Deadlines and cancellation
Every gRPC call should carry a client-set deadline — not a fire-and-forget timeout guessed by the server, but an explicit budget the client propagates down the call chain.
// Client sets a 500ms deadline; if exceeded, the call fails with DEADLINE_EXCEEDED
// and the server-side work is signaled to cancel via the call's Context.
orderServiceStub
.withDeadlineAfter(500, TimeUnit.MILLISECONDS)
.getOrder(request);Deadlines should be propagated, not reset, across a call chain: if service A calls B with a 500ms deadline and B calls C, C should inherit A's remaining budget (say, 300ms left), not get a fresh 500ms. Otherwise a slow downstream call can blow through the caller's actual deadline while every individual hop reports success.
6. gRPC-Gateway: Bridging to HTTP/JSON
gRPC-Gateway generates a reverse-proxy that translates RESTful HTTP/JSON requests into gRPC calls against the same service — one .proto file drives both the internal gRPC contract and the external REST facade.
import "google/api/annotations.proto";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {
option (google.api.http) = {
get: "/v1/orders/{order_id}"
};
}
}A browser client can now call GET /v1/orders/1041 as plain JSON over HTTP, while internal services call the same GetOrder RPC natively over gRPC — both paths are generated from, and stay consistent with, one .proto source of truth.
7. Message Design: Nesting, Maps, and Well-Known Types
Nested messages vs separate top-level messages
Protobuf lets you nest a message definition inside another, scoping it to the parent — useful when a type genuinely has no meaning outside its container.
message Order {
int64 id = 1;
// Nested: Address only ever exists in the context of an Order
message Address {
string street = 1;
string city = 2;
string postal_code = 3;
}
Address shipping_address = 2;
Address billing_address = 3;
}If Address is reused across multiple unrelated messages (Customer, Warehouse, Order), promote it to a top-level message instead — nesting it under Order would force every other file to import and reference Order.Address, coupling unrelated services to Order's definition.
Maps
message Order {
int64 id = 1;
map<string, string> metadata = 2; // arbitrary key-value tags, e.g. {"source": "mobile-app"}
}Maps are convenient for loosely-structured metadata, but every key/value pair is untyped and unvalidated by the schema — prefer explicit fields for anything the server logic actually branches on. A map<string, string> is for data the service passes through, not data it interprets.
Well-known types
Protobuf ships a standard library of reusable message types so teams don't reinvent Timestamp or Duration incompatibly across services.
| Well-known type | Use instead of | Why |
|---|---|---|
google.protobuf.Timestamp | int64 epoch_millis = 1; | Self-documenting, includes nanosecond precision, has generated helper conversions in every target language |
google.protobuf.Duration | int64 timeout_ms = 1; | Same reasoning — explicit unit, no ambiguity about ms vs seconds |
google.protobuf.Empty | A message with no fields, hand-defined per service | Standard "no request/response body needed" marker, recognized by tooling |
google.protobuf.Any | An untyped bytes blob with a manually tracked type | Self-describing — carries its own type URL, so a generic handler can inspect what's inside without an out-of-band schema registry |
google.protobuf.FieldMask | A custom "which fields to update" convention per endpoint | Standard partial-update mechanism, e.g. update_mask: "status,total_amount" on a PATCH-equivalent RPC |
import "google/protobuf/timestamp.proto";
import "google/protobuf/field_mask.proto";
message Order {
int64 id = 1;
google.protobuf.Timestamp created_at = 2;
}
message UpdateOrderRequest {
Order order = 1;
google.protobuf.FieldMask update_mask = 2; // e.g. "status,total_amount" — PATCH semantics for gRPC
}FieldMask is how gRPC expresses the equivalent of a REST PATCH — since a unary RPC always sends the full Order message (unset fields default to zero values, which are indistinguishable from "explicitly set to zero"), the mask tells the server which fields in the payload the client actually intends to change versus which are just unset defaults.
8. Interceptors and Cross-Cutting Concerns
Interceptors are gRPC's equivalent of REST middleware — code that wraps every call (client-side or server-side) without the service implementation needing to know about it. They're the standard place for concerns that would otherwise be copy-pasted into every RPC handler.
| Cross-cutting concern | Handled by |
|---|---|
| Authentication (validate a bearer token / mTLS client cert) | Server interceptor, runs before every handler |
| Structured request/response logging | Server + client interceptor |
| Metrics (latency histograms, error-rate counters per method) | Server interceptor, keyed by RPC method name |
Retry with backoff on UNAVAILABLE | Client interceptor |
| Deadline propagation (Section 5) | Client interceptor that forwards remaining budget from incoming context |
| Distributed tracing (propagate a trace ID across service hops) | Both client and server interceptors, reading/writing trace metadata |
// Server interceptor: reject calls with no/invalid auth token before the handler ever runs
public class AuthInterceptor implements ServerInterceptor {
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
String token = headers.get(AUTH_TOKEN_KEY);
if (!isValid(token)) {
call.close(Status.UNAUTHENTICATED.withDescription("invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
return next.startCall(call, headers);
}
}Interceptors keep service handlers focused purely on business logic — the same Single Responsibility argument from the SOLID Principles guide, applied at the RPC-framework level instead of the class level. A handler that manually checks auth, logs, and records metrics inline has the same "multiple reasons to change" problem as a class doing four unrelated jobs.
Interview Questions
- Why does renaming a protobuf field never break compatibility, but changing its field number always does?
- Walk through all four gRPC RPC types (unary, server-streaming, client-streaming, bidirectional) and give a realistic use case for each.
- What does
reserveddo in a.protofile, and what bug does it prevent? - Why must every proto3 enum have a zero value, and what happens when an old client receives a newer enum value it doesn't recognize?
- Compare gRPC and REST across wire format, browser support, and streaming support. When would you choose each?
- How do gRPC deadlines differ from a simple client-side timeout, and why does deadline propagation matter across a multi-hop call chain?
- What problem does gRPC-Gateway solve, and why is it preferable to maintaining a separate hand-written REST API alongside the gRPC one?
- Map three gRPC status codes to their closest HTTP status code equivalents, and explain one case where the mapping isn't clean.
- When would you nest a message type inside another versus promoting it to a top-level message?
- What does
google.protobuf.FieldMasksolve, and why can't a unary RPC distinguish "field explicitly set to zero" from "field left unset" without it? - What are gRPC interceptors, and which cross-cutting concerns are best implemented as one instead of duplicating logic in every handler?