gRPC with Spring Boot
REST APIs serialize data to JSON, parse it back, and rely on HTTP/1.1 text-based semantics. This works for public APIs and browser clients, but for service-to-service communication inside a microservice architecture, it leaves performance on the table. gRPC uses Protocol Buffers (protobuf) for binary serialization, HTTP/2 for multiplexed transport, and code-generated client/server stubs for type safety. The result: 2-10x better throughput, strict API contracts, and built-in support for streaming.
Spring Boot does not include gRPC support out of the box, but the community grpc-spring-boot-starter (from net.devh) integrates gRPC servers and clients seamlessly into the Spring ecosystem — auto-configuration, dependency injection, Spring Security integration, and Actuator health checks.
Why gRPC for Microservices
REST/JSON gRPC/Protobuf
───────── ─────────────
Serialization: JSON (text, ~1KB overhead) Protobuf (binary, ~100B)
Transport: HTTP/1.1 (one req per conn) HTTP/2 (multiplexed streams)
Contract: OpenAPI (optional, docs) .proto (mandatory, code-gen)
Streaming: SSE (server only) Bidirectional streaming
Type safety: Runtime (hope JSON matches) Compile-time (generated code)
Latency: ~5ms (JSON parse overhead) ~1ms (binary decode)
Browser: Native support Requires gRPC-Web proxyProtobuf Schema Design
Project Structure
├── proto/
│ └── src/main/proto/
│ ├── user_service.proto
│ ├── order_service.proto
│ └── common/
│ ├── pagination.proto
│ └── money.proto
├── user-service/
│ ├── build.gradle
│ └── src/main/java/...
└── order-service/
├── build.gradle
└── src/main/java/...Service Definition
// user_service.proto
syntax = "proto3";
package com.example.user.v1;
option java_multiple_files = true;
option java_package = "com.example.user.v1";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "common/pagination.proto";
service UserService {
// Unary RPCs
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty);
// Server streaming: server sends multiple responses
rpc ListUsers(ListUsersRequest) returns (stream User);
// Client streaming: client sends multiple requests
rpc BulkCreateUsers(stream CreateUserRequest) returns (BulkCreateResponse);
// Bidirectional streaming
rpc SyncUsers(stream UserSyncRequest) returns (stream UserSyncResponse);
}
message User {
string id = 1;
string username = 2;
string email = 3;
string display_name = 4;
string bio = 5;
UserStatus status = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
repeated string roles = 9;
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0;
USER_STATUS_ACTIVE = 1;
USER_STATUS_INACTIVE = 2;
USER_STATUS_SUSPENDED = 3;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string username = 1;
string email = 2;
string display_name = 3;
string bio = 4;
}
message UpdateUserRequest {
string id = 1;
User user = 2;
// Field mask specifies which fields to update
google.protobuf.FieldMask update_mask = 3;
}
message DeleteUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2; // Opaque cursor for pagination
string filter = 3; // e.g., "status=ACTIVE"
string order_by = 4; // e.g., "created_at desc"
}
message BulkCreateResponse {
int32 created_count = 1;
int32 failed_count = 2;
repeated BulkCreateError errors = 3;
}
message BulkCreateError {
int32 index = 1;
string message = 2;
}
message UserSyncRequest {
oneof action {
User upsert = 1;
string delete_id = 2;
}
}
message UserSyncResponse {
string id = 1;
SyncAction action = 2;
bool success = 3;
string error_message = 4;
}
enum SyncAction {
SYNC_ACTION_UNSPECIFIED = 0;
SYNC_ACTION_CREATED = 1;
SYNC_ACTION_UPDATED = 2;
SYNC_ACTION_DELETED = 3;
}Common Types
// common/pagination.proto
syntax = "proto3";
package com.example.common;
option java_multiple_files = true;
option java_package = "com.example.common";
message PageRequest {
int32 page_size = 1;
string page_token = 2;
}
message PageResponse {
string next_page_token = 1;
int32 total_size = 2;
}Build Configuration
Maven
<dependencies>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.25.3:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.63.0:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>Gradle
plugins {
id 'com.google.protobuf' version '0.9.4'
}
dependencies {
implementation 'net.devh:grpc-spring-boot-starter:3.1.0.RELEASE'
}
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.25.3'
}
plugins {
grpc {
artifact = 'io.grpc:protoc-gen-grpc-java:1.63.0'
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}Server Implementation
Service Implementation
@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
private final UserService userService;
private final UserMapper userMapper;
public UserGrpcService(UserService userService, UserMapper userMapper) {
this.userService = userService;
this.userMapper = userMapper;
}
@Override
public void getUser(GetUserRequest request,
StreamObserver<User> responseObserver) {
try {
UserEntity entity = userService.findById(request.getId())
.orElseThrow(() -> Status.NOT_FOUND
.withDescription("User not found: " + request.getId())
.asRuntimeException());
responseObserver.onNext(userMapper.toProto(entity));
responseObserver.onCompleted();
} catch (StatusRuntimeException e) {
responseObserver.onError(e);
} catch (Exception e) {
responseObserver.onError(Status.INTERNAL
.withDescription("Internal error")
.withCause(e)
.asRuntimeException());
}
}
@Override
public void createUser(CreateUserRequest request,
StreamObserver<User> responseObserver) {
// Validate
if (request.getUsername().isBlank()) {
responseObserver.onError(Status.INVALID_ARGUMENT
.withDescription("Username is required")
.asRuntimeException());
return;
}
if (userService.existsByUsername(request.getUsername())) {
responseObserver.onError(Status.ALREADY_EXISTS
.withDescription("Username already taken: " + request.getUsername())
.asRuntimeException());
return;
}
UserEntity entity = userService.create(
request.getUsername(),
request.getEmail(),
request.getDisplayName(),
request.getBio()
);
responseObserver.onNext(userMapper.toProto(entity));
responseObserver.onCompleted();
}
@Override
public void listUsers(ListUsersRequest request,
StreamObserver<User> responseObserver) {
// Server streaming: send users one by one
int pageSize = request.getPageSize() > 0 ? request.getPageSize() : 100;
userService.streamAll(request.getFilter(), pageSize)
.forEach(entity -> {
responseObserver.onNext(userMapper.toProto(entity));
});
responseObserver.onCompleted();
}
@Override
public StreamObserver<CreateUserRequest> bulkCreateUsers(
StreamObserver<BulkCreateResponse> responseObserver) {
// Client streaming: receive multiple create requests
return new StreamObserver<>() {
private final AtomicInteger created = new AtomicInteger(0);
private final AtomicInteger failed = new AtomicInteger(0);
private final List<BulkCreateError> errors =
Collections.synchronizedList(new ArrayList<>());
private int index = 0;
@Override
public void onNext(CreateUserRequest request) {
try {
userService.create(
request.getUsername(),
request.getEmail(),
request.getDisplayName(),
request.getBio());
created.incrementAndGet();
} catch (Exception e) {
failed.incrementAndGet();
errors.add(BulkCreateError.newBuilder()
.setIndex(index)
.setMessage(e.getMessage())
.build());
}
index++;
}
@Override
public void onError(Throwable t) {
log.error("Error in bulk create stream", t);
}
@Override
public void onCompleted() {
responseObserver.onNext(BulkCreateResponse.newBuilder()
.setCreatedCount(created.get())
.setFailedCount(failed.get())
.addAllErrors(errors)
.build());
responseObserver.onCompleted();
}
};
}
}Server Configuration
grpc:
server:
port: 9090
security:
enabled: false # Enable for TLS
cert-chain: classpath:certs/server.crt
private-key: classpath:certs/server.key
max-inbound-message-size: 4MB
max-inbound-metadata-size: 8KB
keep-alive-time: 30s
keep-alive-timeout: 5s
permit-keep-alive-time: 5mClient Stubs
Client Configuration
grpc:
client:
user-service:
address: dns:///user-service:9090
negotiation-type: plaintext # Use TLS in production
enable-keep-alive: true
keep-alive-time: 30s
keep-alive-timeout: 5s
deadline-after: 5s # Default deadline for all callsUsing @GrpcClient
@Service
public class OrderService {
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceBlockingStub userStub;
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceFutureStub userFutureStub;
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceStub userAsyncStub;
public OrderResponse createOrder(CreateOrderRequest request) {
// Blocking call with deadline
User user;
try {
user = userStub
.withDeadlineAfter(3, TimeUnit.SECONDS)
.getUser(GetUserRequest.newBuilder()
.setId(request.getUserId())
.build());
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.NOT_FOUND) {
throw new UserNotFoundException(request.getUserId());
}
if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) {
throw new ServiceTimeoutException("User service timeout");
}
throw new ServiceUnavailableException("User service error", e);
}
// Process order with user data...
return processOrder(request, user);
}
// Async call example
public CompletableFuture<User> getUserAsync(String userId) {
ListenableFuture<User> future = userFutureStub
.withDeadlineAfter(3, TimeUnit.SECONDS)
.getUser(GetUserRequest.newBuilder().setId(userId).build());
return toCompletableFuture(future);
}
private <T> CompletableFuture<T> toCompletableFuture(ListenableFuture<T> lf) {
CompletableFuture<T> cf = new CompletableFuture<>();
Futures.addCallback(lf, new FutureCallback<>() {
@Override
public void onSuccess(T result) { cf.complete(result); }
@Override
public void onFailure(Throwable t) { cf.completeExceptionally(t); }
}, MoreExecutors.directExecutor());
return cf;
}
}Streaming Patterns
Server Streaming: Real-Time Feed
// Server side
@Override
public void watchOrderStatus(WatchOrderRequest request,
StreamObserver<OrderStatusUpdate> responseObserver) {
String orderId = request.getOrderId();
// Register the observer for push updates
orderStatusRegistry.register(orderId, update -> {
responseObserver.onNext(update);
});
// Send current status immediately
OrderStatusUpdate current = orderService.getCurrentStatus(orderId);
responseObserver.onNext(current);
// Stream stays open until client cancels or order reaches terminal state
}
// Client side
public void watchOrder(String orderId, Consumer<OrderStatusUpdate> callback) {
userAsyncStub.watchOrderStatus(
WatchOrderRequest.newBuilder().setOrderId(orderId).build(),
new StreamObserver<>() {
@Override
public void onNext(OrderStatusUpdate update) {
callback.accept(update);
}
@Override
public void onError(Throwable t) {
log.error("Watch stream error for order {}", orderId, t);
// Reconnect after delay
scheduler.schedule(() -> watchOrder(orderId, callback),
5, TimeUnit.SECONDS);
}
@Override
public void onCompleted() {
log.info("Watch stream completed for order {}", orderId);
}
});
}Bidirectional Streaming: Chat
@Override
public StreamObserver<ChatMessage> chat(StreamObserver<ChatMessage> responseObserver) {
String sessionId = UUID.randomUUID().toString();
chatSessions.put(sessionId, responseObserver);
return new StreamObserver<>() {
@Override
public void onNext(ChatMessage message) {
// Broadcast to all other sessions
chatSessions.forEach((id, observer) -> {
if (!id.equals(sessionId)) {
try {
observer.onNext(message);
} catch (StatusRuntimeException e) {
chatSessions.remove(id);
}
}
});
}
@Override
public void onError(Throwable t) {
chatSessions.remove(sessionId);
}
@Override
public void onCompleted() {
chatSessions.remove(sessionId);
responseObserver.onCompleted();
}
};
}Error Handling with Rich Status
gRPC uses status codes (similar to HTTP status codes but more specific):
@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
@Override
public void createUser(CreateUserRequest request,
StreamObserver<User> responseObserver) {
// Rich error details using com.google.rpc.Status
List<String> violations = validate(request);
if (!violations.isEmpty()) {
com.google.rpc.Status status = com.google.rpc.Status.newBuilder()
.setCode(Code.INVALID_ARGUMENT.getNumber())
.setMessage("Validation failed")
.addDetails(Any.pack(BadRequest.newBuilder()
.addAllFieldViolations(violations.stream()
.map(v -> BadRequest.FieldViolation.newBuilder()
.setField(v.split(":")[0])
.setDescription(v.split(":")[1])
.build())
.toList())
.build()))
.build();
responseObserver.onError(StatusProto.toStatusRuntimeException(status));
return;
}
// ... create user
}
}Interceptors
Server Interceptor: Logging and Metrics
@Component
public class GrpcServerLoggingInterceptor implements ServerInterceptor {
private final MeterRegistry meterRegistry;
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String methodName = call.getMethodDescriptor().getFullMethodName();
long startTime = System.nanoTime();
Timer.Sample sample = Timer.start(meterRegistry);
// Extract correlation ID
String correlationId = headers.get(
Metadata.Key.of("x-correlation-id", Metadata.ASCII_STRING_MARSHALLER));
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
log.info("gRPC call started: {}", methodName);
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(
next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(Status status, Metadata trailers) {
long duration = System.nanoTime() - startTime;
sample.stop(Timer.builder("grpc.server.calls")
.tag("method", methodName)
.tag("status", status.getCode().name())
.register(meterRegistry));
log.info("gRPC call completed: {} status={} duration={}ms",
methodName, status.getCode(),
TimeUnit.NANOSECONDS.toMillis(duration));
MDC.clear();
super.close(status, trailers);
}
}, headers)) {};
}
}Health Checks
@Component
public class GrpcHealthService extends HealthGrpc.HealthImplBase {
private final Map<String, HealthCheckResponse.ServingStatus> statusMap =
new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// Overall service status
statusMap.put("", HealthCheckResponse.ServingStatus.SERVING);
statusMap.put("user.v1.UserService", HealthCheckResponse.ServingStatus.SERVING);
}
@Override
public void check(HealthCheckRequest request,
StreamObserver<HealthCheckResponse> responseObserver) {
String service = request.getService();
HealthCheckResponse.ServingStatus status =
statusMap.getOrDefault(service, HealthCheckResponse.ServingStatus.SERVICE_UNKNOWN);
responseObserver.onNext(HealthCheckResponse.newBuilder()
.setStatus(status)
.build());
responseObserver.onCompleted();
}
public void setStatus(String service, HealthCheckResponse.ServingStatus status) {
statusMap.put(service, status);
}
}gRPC vs REST Decision Guide
| Scenario | Recommendation |
|---|---|
| Public API consumed by browsers | REST — gRPC-Web adds complexity |
| Internal service-to-service calls | gRPC — better performance, type safety |
| Streaming data (real-time feeds) | gRPC — native bidirectional streaming |
| Simple CRUD with few consumers | REST — simpler tooling and debugging |
| Polyglot microservices | gRPC — code generation for all languages |
| High-throughput, low-latency | gRPC — binary serialization, HTTP/2 multiplexing |
| Mobile clients (bandwidth-sensitive) | gRPC — 5-10x smaller payloads |
gRPC excels at internal service-to-service communication where performance and type safety matter. For external-facing APIs, REST remains the pragmatic choice. Many production systems use both -- gRPC internally, REST (or GraphQL) externally -- with an API gateway handling the translation at the boundary.
Common Pitfalls
Pitfall 1: Not setting deadlines (timeouts) on gRPC calls
Without deadlines, a slow or unresponsive server causes the client to wait indefinitely, consuming threads and cascading failures across services. Fix: Always set deadlines on client stubs: stub.withDeadlineAfter(3, TimeUnit.SECONDS). Configure a default deadline in the client YAML with deadline-after: 5s.
Pitfall 2: Using blocking stubs in reactive or async code paths
Using UserServiceBlockingStub in a reactive pipeline or async method blocks the event loop or async thread, defeating the purpose of non-blocking code. Fix: Use UserServiceFutureStub for async calls or UserServiceStub (async streaming) for fully non-blocking patterns. Convert ListenableFuture to CompletableFuture for integration with Java async APIs.
Pitfall 3: Not handling gRPC status codes properly on the client side
Catching only generic Exception loses the rich status information (status code, description, metadata) that gRPC provides. Fix: Catch StatusRuntimeException and check e.getStatus().getCode() to differentiate between NOT_FOUND, ALREADY_EXISTS, DEADLINE_EXCEEDED, UNAVAILABLE, etc. Map each to appropriate application exceptions.
Pitfall 4: Forgetting to evolve protobuf schemas safely
Renaming or changing the type of existing fields breaks backward compatibility with older clients or services. Fix: Follow protobuf evolution rules: never reuse field numbers, only add new fields with new numbers, use reserved to prevent reuse of removed field numbers. Use oneof for fields that may have different types in the future.
Pitfall 5: Not implementing health checks for gRPC services
Without gRPC health checks, Kubernetes and load balancers cannot detect unhealthy gRPC services, continuing to route traffic to failing instances. Fix: Implement the standard gRPC health check protocol (grpc.health.v1.Health) with Check and Watch methods. The grpc-spring-boot-starter provides auto-configuration for health checks.
Interview Questions
Q1: What advantages does gRPC have over REST for microservice communication?
Answer
(1) Performance: Protocol Buffers are 2-10x smaller than JSON (binary encoding). (2) HTTP/2: Multiplexed streams over a single connection, no head-of-line blocking. (3) Type safety: Code-generated client and server stubs enforce the API contract at compile time. (4) Streaming: Native support for server streaming, client streaming, and bidirectional streaming. (5) Code generation: Generate client libraries for any language (Java, Go, Python, TypeScript) from .proto files. (6) Latency: Binary serialization and HTTP/2 reduce per-request overhead to ~1ms vs ~5ms for JSON/HTTP/1.1.
Q2: Explain the four types of gRPC service methods.
Answer
(1) Unary RPC: Client sends one request, server returns one response. Like REST. Example: GetUser(GetUserRequest) returns (User). (2) Server streaming: Client sends one request, server returns a stream of responses. Example: real-time feed, ListUsers(ListRequest) returns (stream User). (3) Client streaming: Client sends a stream of requests, server returns one response. Example: bulk upload, BulkCreate(stream CreateRequest) returns (BulkResponse). (4) Bidirectional streaming: Both client and server send streams concurrently. Example: chat, Chat(stream Message) returns (stream Message).
Q3: How do gRPC interceptors work and what are they used for?
Answer
gRPC interceptors are middleware that wrap service calls, similar to servlet filters in HTTP. Server interceptors (ServerInterceptor) intercept incoming calls for logging, authentication, metrics, rate limiting, and request validation. Client interceptors (ClientInterceptor) intercept outgoing calls for adding metadata headers (auth tokens, correlation IDs), retries, and logging. Interceptors access the call metadata, can modify headers, and wrap the ServerCallListener (server) or ClientCall (client) to intercept request/response lifecycle events.
Q4: How does error handling differ between gRPC and REST?
Answer
REST uses HTTP status codes (200, 404, 500) with a JSON error body. gRPC uses its own status codes (OK, NOT_FOUND, ALREADY_EXISTS, INVALID_ARGUMENT, UNAVAILABLE, DEADLINE_EXCEEDED, etc.) which are more specific than HTTP codes. Error details are sent as Status with a description string and optional Metadata trailers. For rich errors, use com.google.rpc.Status with Any-packed details like BadRequest.FieldViolation for validation errors. The StatusRuntimeException class carries both the status code and description.
Q5: When should you use gRPC vs REST vs GraphQL?
Answer
gRPC: Internal service-to-service communication where performance, type safety, and streaming are priorities. Not suitable for browser clients (requires gRPC-Web proxy). REST: External-facing APIs consumed by browsers, third-party developers, and mobile apps. Best for simple CRUD with wide tooling support. GraphQL: APIs with multiple clients needing different data shapes, deeply nested data models, or over-fetching problems. All three can coexist: gRPC for internal communication, REST or GraphQL for external APIs, with an API gateway handling protocol translation at the boundary.