05-schema-api-design

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.

August 11, 2026
lldgRPCprotobufstreamingcontractsRPC

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

protobuf
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 typeRequestResponseTypical use
UnarySingleSingleCRUD-style operations, direct equivalent of REST
Server streamingSingleStreamLive feeds, subscriptions, large paginated dumps
Client streamingStreamSingleBatch uploads, telemetry aggregation
Bidirectional streamingStreamStreamChat, 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.

ChangeSafe?Why
Add a new field with a new, unused numberSafeOld readers ignore unknown fields; new readers see it as unset/default on old data
Rename a field, keep the same numberSafeWire format only encodes the number — the name is compile-time only, doesn't affect serialized bytes
Remove a field, but don't reuse its numberSafe (mark as reserved)Old data with that field is simply ignored by new code
Reuse a previously-used field number for a different fieldBreakingOld serialized data will be misinterpreted as the new field's type — silent data corruption
Change a field's numberBreakingOld and new code disagree on what that field's bytes mean
Change a field's type incompatibly (e.g. int32 to string)BreakingWire 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 safeSame wire type (varint); values may just get wider
Add a new value to an enumSafeOld code treats an unrecognized enum value as the zero/unknown value (proto3)
Add a new RPC method to a serviceSafePurely additive — unrelated to existing methods
Change a field from singular to repeatedBreakingWire representation differs
protobuf
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

StrategyExampleWhen to use
Package versioningpackage orders.v1;package orders.v2;Breaking change to the service/message shape — a genuinely new contract
Additive evolution within a versionAdd optional fields/RPCs to orders.v1The default path — most changes should be additive, no version bump needed
New RPC method, same messageGetOrderV2(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

gRPCREST (JSON over HTTP)
Wire formatBinary (Protobuf) — compact, fast to (de)serializeText (JSON) — human-readable, larger payloads
TransportHTTP/2 — multiplexed streams over one connectionTypically HTTP/1.1, one request per connection round-trip (or HTTP/2 without native streaming semantics)
ContractStrongly typed, compiled .proto — client/server generated in lockstepLoosely typed, convention-enforced (OpenAPI helps but isn't compiled in)
StreamingNative: unary, server/client/bidi streamingNot native — needs SSE, WebSockets, or long-polling bolted on
Browser supportNeeds gRPC-Web + a proxy translation layer; not native fetch/XHRNative — every browser speaks HTTP/JSON directly
Human debuggabilityNeeds grpcurl or a proxy — binary payload isn't curl-able as-iscurl/Postman/browser devtools work directly
Tooling ecosystemStrong in polyglot microservice backends (Go, Java, C++)Universal — every language, every platform, every third-party integrator
Best fitInternal service-to-service calls, especially latency-sensitive, high-throughput, or streaming-heavyPublic 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 statusRough REST equivalentMeaning
OK200Success
INVALID_ARGUMENT400Malformed request
UNAUTHENTICATED401Missing/invalid credentials
PERMISSION_DENIED403Authenticated but not allowed
NOT_FOUND404Resource doesn't exist
ALREADY_EXISTS409Conflict on create
FAILED_PRECONDITION409/422State doesn't allow this operation (e.g. cancel a shipped order)
RESOURCE_EXHAUSTED429Rate limit or quota exceeded
INTERNAL500Unexpected server error
UNAVAILABLE503Server temporarily down — safe to retry with backoff
DEADLINE_EXCEEDED504Call didn't complete before the deadline
protobuf
// 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";
java
// 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.

java
// 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.

protobuf
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.

protobuf
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

protobuf
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 typeUse instead ofWhy
google.protobuf.Timestampint64 epoch_millis = 1;Self-documenting, includes nanosecond precision, has generated helper conversions in every target language
google.protobuf.Durationint64 timeout_ms = 1;Same reasoning — explicit unit, no ambiguity about ms vs seconds
google.protobuf.EmptyA message with no fields, hand-defined per serviceStandard "no request/response body needed" marker, recognized by tooling
google.protobuf.AnyAn untyped bytes blob with a manually tracked typeSelf-describing — carries its own type URL, so a generic handler can inspect what's inside without an out-of-band schema registry
google.protobuf.FieldMaskA custom "which fields to update" convention per endpointStandard partial-update mechanism, e.g. update_mask: "status,total_amount" on a PATCH-equivalent RPC
protobuf
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 concernHandled by
Authentication (validate a bearer token / mTLS client cert)Server interceptor, runs before every handler
Structured request/response loggingServer + client interceptor
Metrics (latency histograms, error-rate counters per method)Server interceptor, keyed by RPC method name
Retry with backoff on UNAVAILABLEClient 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
java
// 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 reserved do in a .proto file, 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.FieldMask solve, 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?