gRPC in Java Microservices
gRPC for internal service calls: Protocol Buffers and schema evolution, the four RPC types, deadlines and interceptors, Spring Boot integration, and an honest comparison with REST.
gRPC is the right tool for a specific job: high-volume, low-latency, internal service-to-service calls with a stable schema. Outside that, its costs — codegen, opaque payloads, weaker tooling — usually outweigh the benefits.
Key Takeaways
- Field numbers are the contract, not field names. Never reuse one.
- Four RPC types: unary, server streaming, client streaming, bidirectional.
- Always set a deadline — without one a hung server holds your call indefinitely.
- Interceptors are where auth, tracing and logging belong.
- Use it for internal hot paths; keep REST for public and browser-facing APIs.
The schema
syntax = "proto3";
package acme.order.v1;
option java_multiple_files = true;
option java_package = "com.acme.order.grpc";
import "google/protobuf/timestamp.proto";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (stream Order); // server streaming
rpc ImportOrders(stream Order) returns (ImportSummary); // client streaming
rpc WatchOrders(stream WatchRequest) returns (stream OrderEvent); // bidirectional
}
message Order {
string id = 1;
string customer_id = 2;
Money total = 3;
OrderStatus status = 4;
repeated OrderLine lines = 5;
google.protobuf.Timestamp created_at = 6;
// Field 7 held a deprecated 'legacy_total'. Reserving it stops a future
// developer reusing the number and silently misreading old messages.
reserved 7;
reserved "legacy_total";
string channel = 8; // added later; old clients ignore it
}
message Money {
int64 minor_units = 1; // never a float for money
string currency = 2; // ISO 4217
}
enum OrderStatus {
// proto3 requires a zero value, and it is what an old client sees for an
// enum value it does not know.
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PLACED = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_CANCELLED = 4;
}Two conventions carry most of the compatibility story. Field numbers are the wire format — names
exist only in the generated code, so renaming a field is safe and renumbering one is catastrophic.
And the zero enum value is what a client receiving an unknown value decodes to, which is why
UNSPECIFIED should always be zero rather than a real status.
Numbers 1-15 encode in a single byte; reserve them for fields that appear in every message.
One proto3 subtlety is worth learning before it costs you a bug rather than after. Scalar fields have
no presence by default: a string that was never set and one explicitly set to "" are
indistinguishable on the wire, and both decode to the empty string. A partial-update RPC therefore
cannot tell "leave this field alone" from "clear this field". Mark the field optional, which restores
presence tracking and generates hasChannel(), or carry a FieldMask so the request states which
fields it intends to touch.
Where the .proto files live deserves a deliberate decision rather than an accidental one. Copying
them between repositories guarantees drift, and the drift is silent because both sides still compile
perfectly well against their own stale copy. A dedicated schema repository published as a versioned
artefact — or a registry such as Buf — gives you one source of truth and, more valuably,
breaking-change detection in CI, so renumbering a field fails a pull request instead of production.
Server and client
@GrpcService
public class OrderGrpcService extends OrderServiceGrpc.OrderServiceImplBase {
private final OrderQueryService orders;
@Override
public void getOrder(GetOrderRequest request, StreamObserver<Order> observer) {
try {
observer.onNext(mapper.toProto(orders.find(request.getId())));
observer.onCompleted();
} catch (ResourceNotFoundException ex) {
// gRPC status codes, not HTTP. NOT_FOUND, PERMISSION_DENIED,
// INVALID_ARGUMENT, DEADLINE_EXCEEDED, UNAVAILABLE.
observer.onError(Status.NOT_FOUND
.withDescription("no order " + request.getId())
.asRuntimeException());
}
}
@Override
public void listOrders(ListOrdersRequest request, StreamObserver<Order> observer) {
// Streaming keeps memory flat regardless of result size — the whole
// point compared to returning a large list.
try (Stream<OrderEntity> stream = orders.streamByCustomer(request.getCustomerId())) {
stream.map(mapper::toProto).forEach(observer::onNext);
observer.onCompleted();
}
}
}@Service
public class OrderClient {
@GrpcClient("order-service")
private OrderServiceGrpc.OrderServiceBlockingStub stub;
public Order fetch(String id) {
return stub
// Without a deadline, a hung server holds this call forever.
// The deadline propagates to downstream calls automatically.
.withDeadlineAfter(2, TimeUnit.SECONDS)
.getOrder(GetOrderRequest.newBuilder().setId(id).build());
}
}Deadline propagation is one of gRPC's genuinely nice properties. A two-second deadline set at the edge travels with the call, so a service three hops down knows it has 1.4 seconds left and does not start work that can no longer be delivered.
Interceptors
@GrpcGlobalServerInterceptor
public class AuthInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
String header = headers.get(AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
call.close(Status.UNAUTHENTICATED.withDescription("missing token"), new Metadata());
return new ServerCall.Listener<>() { };
}
try {
Jwt jwt = decoder.decode(header.substring(7));
Context context = Context.current().withValue(PRINCIPAL, jwt.getSubject());
return Contexts.interceptCall(context, call, headers, next);
} catch (JwtException ex) {
call.close(Status.UNAUTHENTICATED.withDescription("invalid token"), new Metadata());
return new ServerCall.Listener<>() { };
}
}
}Interceptors are gRPC's equivalent of servlet filters, and the same concerns belong there: authentication, correlation propagation, metrics and access logging. Putting them in each service implementation instead is how they end up inconsistent.
Errors
// gRPC has 17 status codes. Map domain exceptions onto them deliberately.
private StatusRuntimeException toStatus(Exception ex) {
return switch (ex) {
case ResourceNotFoundException e -> Status.NOT_FOUND.withDescription(e.getMessage()).asRuntimeException();
case ValidationException e -> Status.INVALID_ARGUMENT.withDescription(e.getMessage()).asRuntimeException();
case AccessDeniedException e -> Status.PERMISSION_DENIED.asRuntimeException();
case OptimisticLockException e -> Status.ABORTED.withDescription("conflict").asRuntimeException();
case RateLimitException e -> Status.RESOURCE_EXHAUSTED.asRuntimeException();
default -> Status.INTERNAL.asRuntimeException();
};
}Note the last line. INTERNAL with no description is deliberate — the same rule as HTTP applies, and
an exception message leaked to a caller is reconnaissance.
For structured error detail, google.rpc.Status supports typed detail messages, which is the gRPC
equivalent of RFC 7807's extension members.
gRPC or REST
| gRPC | REST/JSON | |
|---|---|---|
| Payload size | ~3-10x smaller | Larger |
| Parse cost | Low, binary | Higher |
| Schema | Enforced, versioned | Convention or OpenAPI |
| Streaming | Native, four modes | SSE or WebSocket |
| Browser support | Via proxy only | Native |
| Debuggability | Needs grpcurl | curl and a browser |
| Load balancing | Needs L7 awareness | Any L4 balancer |
That last row causes real operational surprise. gRPC multiplexes many calls over one long-lived HTTP/2 connection, so a layer-4 load balancer pins all traffic from one client to one server. You need an L7-aware balancer, a service mesh, or client-side round-robin — otherwise adding servers does not distribute load.
What to take away
Treat field numbers as the contract and reserve retired ones. Set a deadline on every call and let it propagate. Put auth, tracing and metrics in interceptors. Then be selective: gRPC for internal hot paths where you own both sides, REST everywhere else — and remember it needs L7-aware load balancing to distribute at all.
Frequently Asked Questions
Is gRPC faster than REST?
Can browsers call gRPC?
How do I evolve a proto without breaking clients?
Related tutorials
- API Security & the OWASP API Top 10The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.
- Database Sharding & Scaling StrategiesScaling past one database: read replicas and routing, choosing a shard key you will not regret, hash versus range sharding, cross-shard queries, and migrating without downtime.
- Transaction Management Deep DiveTransactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
- Enterprise Integration PatternsThe vocabulary of system integration: routers, splitters, aggregators, content enrichers and the claim check, implemented with Apache Camel and Spring Integration.