Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
On this page

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

order.proto
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

OrderGrpcService.java
@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();
        }
    }
}
OrderGrpcClient.java
@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

AuthInterceptor.java
@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

ErrorMapping.java
// 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 is for internal hot paths with a stable schema. Everywhere else, REST's ubiquity wins.
gRPCREST/JSON
Payload size~3-10x smallerLarger
Parse costLow, binaryHigher
SchemaEnforced, versionedConvention or OpenAPI
StreamingNative, four modesSSE or WebSocket
Browser supportVia proxy onlyNative
DebuggabilityNeeds grpcurlcurl and a browser
Load balancingNeeds L7 awarenessAny 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?
For internal service calls, meaningfully — Protobuf payloads are several times smaller than equivalent JSON and parse faster, and HTTP/2 multiplexes many calls over one connection. The difference is negligible for a low-volume endpoint and substantial for a hot path handling thousands of calls per second.
Can browsers call gRPC?
Not directly, because browsers cannot control HTTP/2 frames. gRPC-Web plus a proxy such as Envoy makes it work with some feature loss, notably client streaming. For browser-facing APIs, REST or GraphQL remains simpler.
How do I evolve a proto without breaking clients?
Add new fields with new numbers; never reuse a number, and mark removed ones reserved. Unknown fields are preserved rather than rejected, so an old client reading a new message ignores what it does not recognise. Changing a field type or renumbering is always breaking.

Related tutorials