gRPC Service Design
gRPC Service Design
gRPC service design patterns provide the vocabulary for contract-first RPC with Protocol Buffers, four streaming interaction types, deadline propagation across service chains, and interceptor middleware cross-linked to Decorator and Pipes-and-Filters lineage.
Intent
gRPC is a contract-first RPC framework built on HTTP/2 and Protocol Buffers. The service contract is defined in a .proto file; code generation produces typed stubs for all target languages. Unlike REST (request-response only), gRPC natively supports four RPC interaction patterns — unary, server streaming, client streaming, and bidirectional streaming — all defined in the same .proto service block using the stream keyword modifier.
This note covers three complementary design concerns: (1) the four RPC types and their .proto declarations, (2) deadline propagation as a mandatory operational discipline, and (3) interceptors as the canonical cross-cutting concern mechanism.
Cross-reference error models: gRPC uses numeric status codes (0-16) rather than HTTP status codes — for REST error contracts see [[REST-API-Design]].
When NOT to Use
- Browser clients without a proxy — gRPC uses HTTP/2 trailers which browsers do not expose. gRPC-Web requires a proxy (Envoy, grpc-web). If your consumers are browser-based SPAs, REST or GraphQL is simpler. gRPC-Web also does not support bidirectional streaming.
- Simple CRUD APIs with no streaming or performance requirements — gRPC adds protobuf schema management, code generation toolchain, and binary debugging complexity. If your API is flat request-response JSON with no streaming, REST provides the same capability with simpler tooling. See
[[REST-API-Design]]. - Public APIs consumed by third-party developers — gRPC requires client code generation and protobuf tooling. REST with OpenAPI/JSON is the universal standard for public APIs; third-party developers expect curl-testable endpoints.
- When your team lacks protobuf schema evolution discipline — Field numbers in protobuf are a permanent wire contract. Renumbering or reusing field numbers causes silent data corruption. If the team does not enforce field number governance, JSON-based protocols are safer.
Field numbers are a permanent part of the wire contract. Do not renumber or reuse field numbers. See protobuf.dev field numbers guide for rules on field reservation and evolution. This note does not cover protobuf syntax — it focuses on gRPC service design patterns.
When to Use
- Internal service-to-service communication where both sides are generated from the same
.protocontract (microservices, platform APIs) - Streaming use cases: live feeds (server streaming), upload/aggregation (client streaming), real-time collaboration (bidirectional streaming)
- Performance-sensitive paths where binary serialisation (protobuf) and HTTP/2 multiplexing provide measurable latency reduction over JSON/REST
- Polyglot environments where a single
.protofile generates type-safe clients in multiple languages (TypeScript, Java, Go, Python)
How It Works
The Four RPC Types (GRPC-01)
All four streaming variants are declared in the .proto service definition using the stream keyword. The contract is the same file regardless of language target.
// Source: grpc.io/docs/languages/node/basics/ — four RPC types
// NOTE: protobuf field numbering and syntax versioning are out of scope.
// See https://protobuf.dev/programming-guides/proto3/ for field number rules.
syntax = "proto3";
package chat;
service ChatService {
// Unary: one request, one response
rpc GetMessage (GetMessageRequest) returns (Message);
// Server streaming: one request, stream of responses
rpc ListMessages (ListRequest) returns (stream Message);
// Client streaming: stream of requests, one response
rpc SendBatch (stream Message) returns (BatchResult);
// Bidirectional streaming: independent read/write streams
rpc Chat (stream Message) returns (stream Message);
}
message Message {
string sender = 1;
string text = 2;
}
message GetMessageRequest { string id = 1; }
message ListRequest { string room_id = 1; }
message BatchResult { int32 count = 1; }| RPC Type | stream keyword | Use Case |
|---|---|---|
| Unary | neither | Most CRUD operations |
| Server streaming | returns (stream Response) | Live feeds, large result sets |
| Client streaming | (stream Request) returns | Upload, aggregation |
| Bidirectional | both | Chat, real-time collaboration |
Deadline Propagation (GRPC-01)
gRPC has no default deadline — every client must explicitly set one. The key rule from grpc.io: "By default, gRPC does not set a deadline, which means it is possible for a client to end up waiting for a response effectively forever."
In service chains (A calls B calls C), deadlines are absolute timestamps. When B forwards to C, it subtracts elapsed time. gRPC libraries handle this conversion when you forward the context.
gRPC does not set a deadline by default. A client without a deadline can hang indefinitely. Every call site must set { deadline } (TypeScript) or .withDeadlineAfter(n, UNIT) (Java). Warning signs: callers never see timeouts in testing but hang in production under load.
TypeScript deadline pattern:
// Source: grpc.io/docs/guides/deadlines
// Always set explicit deadline on every call
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5); // 5 second budget
const call = client.chat(
{ metadata: new grpc.Metadata() },
{ deadline } // passed as CallOptions
);Java deadline pattern:
// Source: grpc.io/docs/guides/deadlines
// Set deadline via stub.withDeadlineAfter()
ChatServiceGrpc.ChatServiceStub stub = ChatServiceGrpc
.newStub(channel)
.withDeadlineAfter(5, TimeUnit.SECONDS);Deadline budget propagation in service chains: When service A (5s budget) calls service B, B should forward the remaining budget minus a safety margin to downstream calls. Do not set a fresh 5s deadline on the downstream call — that extends the total chain duration well beyond the original budget. Pass the context and let gRPC subtract elapsed time automatically.
Interceptors (GRPC-01)
Interceptors implement cross-cutting concerns (logging, auth, tracing) for all RPCs. Each interceptor wraps the call — structurally identical to [[Decorator-Pattern]] — and the chain of interceptors executes sequentially like [[Pipes-and-Filters]].
Interceptor ordering matters: Logging -> Caching logs ALL requests (application-level view). Caching -> Logging logs only network requests (network-level view). Java ServerBuilder.intercept() prepends — outermost = first to execute on request, last on response.
TypeScript client interceptor skeleton (grpc-js):
// Source: github.com/grpc/proposal/blob/master/L5-node-client-interceptors.md
// InterceptingCall wraps the next call — Decorator structural pattern
// @grpc/grpc-js — pure JS implementation (deprecated 'grpc' package not used)
import * as grpc from '@grpc/grpc-js';
const loggingInterceptor: grpc.Interceptor = (options, nextCall) => {
return new grpc.InterceptingCall(nextCall(options), {
start(metadata, listener, next) {
console.log(`[gRPC] ${options.method_definition.path}`);
next(metadata, {
onReceiveStatus(status, nextStatus) {
console.log(`[gRPC] status=${status.code}`);
nextStatus(status);
}
});
}
});
};
// Attach interceptors as channel options (not per-call)
const channel = new grpc.Client(address, creds, {
interceptors: [loggingInterceptor]
});Java server interceptor skeleton (grpc-java):
// Source: grpc.io/docs/guides/interceptors
// ServerInterceptor wraps the ServerCall — Decorator structural pattern
public class LoggingInterceptor implements ServerInterceptor {
@Override
public <Req, Resp> ServerCall.Listener<Req> interceptCall(
ServerCall<Req, Resp> call,
Metadata headers,
ServerCallHandler<Req, Resp> next) {
System.out.println("RPC: " + call.getMethodDescriptor().getFullMethodName());
return next.startCall(call, headers); // delegate to next — same as Decorator
}
}
// Register on server build
Server server = ServerBuilder.forPort(50051)
.addService(new ChatServiceImpl())
.intercept(new LoggingInterceptor()) // outermost = first to execute
.build();For downstream failure isolation, see [[Circuit-Breaker-Pattern]].
Anti-patterns:
- Using the deprecated
grpcnpm package — always use@grpc/grpc-js(pure JS, no node-gyp) - Adding interceptors per-call instead of per-channel — interceptors are channel-level configuration
- Registering Java interceptors without documenting order — prepend semantics (outermost = first) are not obvious
Sequence Diagram
Bidirectional streaming RPC with deadline propagation -- deadline budget decreases through service chain; no default deadline exists.
sequenceDiagram
participant C as Client
participant CH as gRPC Channel<br/>(HTTP/2)
participant S as Chat Server
participant DS as Downstream<br/>Service
Note over C,S: Client sets explicit deadline: 5s budget
C->>CH: Open bidi stream: Chat()<br/>deadline = now + 5s
CH->>S: HTTP/2 stream established<br/>HEADERS + deadline metadata
C->>S: stream.write(msg1)<br/>{ sender: "alice", text: "hello" }
S-->>C: stream.write(reply1)<br/>{ sender: "bot", text: "hi alice" }
C->>S: stream.write(msg2)<br/>{ sender: "alice", text: "status?" }
Note over S,DS: Server propagates remaining budget<br/>5s - elapsed = 3.2s remaining
S->>DS: GetStatus(req)<br/>deadline = now + 3.2s
DS-->>S: StatusResponse (within budget)
S-->>C: stream.write(reply2)<br/>{ sender: "bot", text: "all good" }
Note over C,S: Deadline expires (5s elapsed)
rect rgb(255, 230, 230)
S-->>C: DEADLINE_EXCEEDED<br/>(gRPC status code 4)
Note over C,CH: Both streams closed --<br/>client receives cancellation
end
Note over C,DS: No default deadline in gRPC --<br/>every call site must set one explicitly
TypeScript Example — Bidirectional Streaming (GRPC-02)
// Source: grpc.io/docs/languages/node/basics/ + @grpc/grpc-js npm docs
// @grpc/grpc-js — pure JS implementation (deprecated 'grpc' package not used)
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const PROTO_PATH = './chat.proto';
const packageDef = protoLoader.loadSync(PROTO_PATH);
const proto = grpc.loadPackageDefinition(packageDef) as any;
// --- Server side ---
function chatHandler(
call: grpc.ServerDuplexStream<Message, Message>
): void {
call.on('data', (msg: Message) => {
// echo back — replace with real routing logic
call.write({ sender: 'server', text: `echo: ${msg.text}` });
});
call.on('end', () => call.end());
}
// --- Client side ---
const client = new proto.chat.ChatService(
'localhost:50051',
grpc.credentials.createInsecure()
);
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 30);
const stream = client.chat({ deadline }); // returns ClientDuplexStream
stream.write({ sender: 'client', text: 'hello' });
stream.on('data', (msg: Message) => console.log('received:', msg.text));
stream.on('end', () => console.log('server closed stream'));Java Example — Bidirectional Streaming (GRPC-02)
// Source: grpc.io/docs/languages/java/basics/ (official tutorial)
// Both client and server exchange StreamObserver instances
// Server-side: implement the generated service stub
public class ChatServiceImpl extends ChatServiceGrpc.ChatServiceImplBase {
@Override
public StreamObserver<Message> chat(StreamObserver<Message> responseObserver) {
return new StreamObserver<Message>() {
@Override public void onNext(Message msg) {
// echo back to client — replace with real routing
responseObserver.onNext(
Message.newBuilder().setSender("server")
.setText("echo: " + msg.getText()).build()
);
}
@Override public void onError(Throwable t) {
responseObserver.onError(t);
}
@Override public void onCompleted() {
responseObserver.onCompleted();
}
};
}
}
// Client-side: async stub with explicit deadline
ChatServiceGrpc.ChatServiceStub asyncStub = ChatServiceGrpc
.newStub(channel)
.withDeadlineAfter(30, TimeUnit.SECONDS);
StreamObserver<Message> requestObserver = asyncStub.chat(
new StreamObserver<Message>() {
@Override public void onNext(Message msg) {
System.out.println("received: " + msg.getText());
}
@Override public void onError(Throwable t) { /* handle */ }
@Override public void onCompleted() { System.out.println("done"); }
}
);
requestObserver.onNext(Message.newBuilder().setSender("client").setText("hello").build());
requestObserver.onCompleted();Lineage
Chain 15 (Protocol Contract):
- Lineage Backward: gRPC interceptors are the protocol-level expression of
[[Decorator-Pattern]](wrapping) and[[Pipes-and-Filters]](pipeline processing) — these GoF/EIP patterns provide the structural vocabulary for understanding interceptor chains - Lineage Forward: Phase 27
[[API-Protocol-Selection-MOC]]will position gRPC in the protocol comparison alongside REST and GraphQL
Related Concepts
| Concept | Relationship |
|---|---|
[[Decorator-Pattern]] | Interceptors wrap the call with the same interface — structurally identical to Decorator |
[[Pipes-and-Filters]] | Interceptor chain is a processing pipeline — ordering determines behaviour |
[[Circuit-Breaker-Pattern]] | Downstream failure isolation for gRPC service calls |
[[REST-API-Design]] | Error model comparison: gRPC status codes (0-16) vs REST HTTP status + RFC 9457 |
[[Operational-API-Patterns]] | Retry and deadline patterns overlap with operational API concerns |
[[GraphQL-API-Design]] | Protocol comparison: gRPC binary/streaming vs GraphQL field selection |
Sources
- grpc.io/docs/languages/node/basics/ — four RPC types, Node.js patterns
- grpc.io/docs/guides/deadlines/ — deadline propagation, no-default-deadline rule
- grpc.io/docs/guides/interceptors/ — interceptor use cases, ordering rules
- grpc.io/docs/languages/java/basics/ — StreamObserver pattern, Java bidi streaming
- github.com/grpc/proposal/blob/master/L5-node-client-interceptors.md — grpc-js client interceptor API
- npm registry:
@grpc/grpc-js1.14.3,@grpc/proto-loader0.8.0 - grpc-java 1.80.0 (github.com/grpc/grpc-java/releases)
Installation Reference
# TypeScript / Node.js
npm install @grpc/grpc-js @grpc/proto-loader<!-- Maven (Java) — grpc-java 1.80.0 -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.80.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.80.0</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.80.0</version>
</dependency>