Here is the English translation of the article body:
When building internal communication between microservices, there are situations where the overhead of REST starts to bother you. JSON serialization, the round trips of headers, and the tendency for schemas to rely on documentation—these quietly add up in high-frequency calls between internal services.
That’s where gRPC comes up as a third option. It takes a fundamentally different approach from REST (Implementing CRUD for a REST API in Spring Boot) or GraphQL (Getting Started with GraphQL in Spring Boot), and its strength lies in low-latency internal communication. In this article, we’ll walk through the whole flow—from dependency setup and .proto definitions to server and client implementation, error conversion, interceptors, and finally how to decide which option to use.
The Basics of gRPC and Protocol Buffers
gRPC is an RPC framework that exchanges binary data over HTTP/2. It uses Protocol Buffers (Protobuf) to serialize payloads, and a schema file called .proto serves as the contract between the server and the client. Because code is generated from this contract, it’s type-safe while keeping payloads small, making it easier to lean toward lower latency than JSON-based REST.
There are four communication styles:
- unary (one request, one response—the basic form closest to REST)
- server streaming (multiple responses for a single request)
- client streaming (one response for multiple requests)
- bidirectional streaming (two-way)
In this article, we’ll focus mainly on unary, which is the most commonly used. The first thing to keep in mind is that it’s schema-driven. Sharing the .proto generates the same types on both the server and the client, so mismatches from field additions or type changes can be detected at compile time.
Dependency Setup and Build Configuration
This article assumes Spring Boot 3.x / Java 17 or later, and has been verified with grpc-spring (net.devh) 3.1.0.RELEASE, protoc 3.25.5, and protoc-gen-grpc-java 1.68.0. The gRPC core itself (grpc-java) is the 1.68 line. Since the supported Spring Boot and gRPC versions change with each major version of the starter, we recommend checking the release notes for the version that fits your environment before pinning it.
Another point that’s easy to mix up is the starter structure. The net.devh starter is split into the grpc-server-spring-boot-starter for the server and the grpc-client-spring-boot-starter for the client. If a single app handles both the server and the client, include both.
<dependencies>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
</dependencies>
Next is the plugin that generates Java code from .proto. To run the protoc core and the gRPC plugin, we configure the protobuf-maven-plugin.
<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.5:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.68.0:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Place your .proto files under src/main/proto. When you build, the message classes and stubs are generated under target/generated-sources/protobuf and are automatically recognized as a source set. If you use Gradle, you can achieve equivalent configuration with the com.google.protobuf plugin. When the generated artifacts aren’t visible in your IDE, building once and then reloading the Generated Sources often resolves it.
Defining Services and Messages in .proto
As our implementation target, we’ll define a simple service that returns a single user’s information.
syntax = "proto3";
package user;
option java_package = "com.example.grpc.user";
option java_multiple_files = true;
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
}
message GetUserRequest {
int64 id = 1;
}
message UserResponse {
int64 id = 1;
string name = 2;
string email = 3;
}
The = 1 and = 2 on the fields are field numbers, which serve as identifiers on the wire. A key to preserving backward compatibility is to never change a number once it’s been used. Adding java_multiple_files = true generates each message as a separate file, making them easier to work with. We won’t cover the full grammar here, but this is roughly all you need for the implementation. When you run the build, a stub base class called UserServiceGrpc and each message class are generated.
Implementing the Server with @GrpcService
Extend the generated UserServiceGrpc.UserServiceImplBase and override the RPC methods. Annotating the class with @GrpcService registers it as a Bean and automatically exposes it on the gRPC server.
One thing to watch here is the repository’s return value. Spring Data JPA’s findById returns Optional<User>, so use orElseThrow to handle the not-found case. This branch flows directly into the Status conversion that follows.
@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
private final UserRepository repository;
public UserGrpcService(UserRepository repository) {
this.repository = repository;
}
@Override
public void getUser(GetUserRequest request,
StreamObserver<UserResponse> responseObserver) {
User user = repository.findById(request.getId())
.orElseThrow(() -> Status.NOT_FOUND
.withDescription("user not found: " + request.getId())
.asRuntimeException());
UserResponse response = UserResponse.newBuilder()
.setId(user.getId())
.setName(user.getName())
.setEmail(user.getEmail())
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
The response is passed to the StreamObserver rather than being returned. The flow is to send the result with onNext and signal completion with onCompleted.
Extending to server streaming
With unary, onNext is called once, but if you want server streaming, all you need to do is change the return in the .proto to returns (stream UserResponse), call onNext multiple times in a loop, and then call onCompleted. If you first get comfortable with unary, streaming is a natural extension.
Calling from a Client with @GrpcClient
On the side that calls from another service, inject the stub with @GrpcClient. Write the channel connection settings in application.properties.
# Server-side port
grpc.server.port=9090
# Client-side connection target ("user-service" is a logical name)
grpc.client.user-service.address=static://localhost:9090
grpc.client.user-service.negotiation-type=plaintext
During development, plaintext is sufficient for negotiation-type, but in production you should enable TLS. Next is the client implementation.
@Service
public class UserClient {
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceBlockingStub stub;
public UserResponse fetchUser(long id) {
GetUserRequest request = GetUserRequest.newBuilder()
.setId(id)
.build();
return stub.getUser(request);
}
}
BlockingStub is a synchronous call and can be written with a REST-like feel, so it’s the easiest to work with at first. When you want asynchronous behavior, injecting UserServiceGrpc.UserServiceFutureStub lets you receive a ListenableFuture<UserResponse>. When you want to parallelize calls to multiple services or shave down wait times, you’d choose the FutureStub side—that’s how you decide between them.
Exception Handling and Conversion to gRPC Status
gRPC represents errors not with HTTP status codes but with its own Status codes. In the server implementation shown earlier, we threw Status.NOT_FOUND via orElseThrow. This is the basic form of error representation in gRPC.
However, writing try/catch or branching inside every method is cumbersome. Using grpc-spring-boot-starter’s @GrpcExceptionHandler, you can perform conversions cross-cuttingly, just like Spring MVC’s @ExceptionHandler.
@GrpcAdvice
public class GrpcExceptionAdvice {
@GrpcExceptionHandler(UserNotFoundException.class)
public StatusRuntimeException handleNotFound(UserNotFoundException e) {
return Status.NOT_FOUND
.withDescription(e.getMessage())
.asRuntimeException();
}
}
With this in place, the service implementation only needs to straightforwardly throw the domain’s UserNotFoundException, and the Status conversion logic is gathered in one place. When you want to pass additional error information, you can carry it in Metadata. On the client side, catch the StatusRuntimeException and branch on the code.
try {
return stub.getUser(request);
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.NOT_FOUND) {
return null;
}
throw e;
}
If you keep NOT_FOUND, INVALID_ARGUMENT, and UNAUTHENTICATED in mind, you can think of them as mapping to REST’s 404/400/401.
Injecting Authentication and Logging with Interceptors
Cross-cutting concerns like authentication and logging should be consolidated into a ServerInterceptor rather than written in each method. Here’s an example of extracting and validating a token from the Metadata on the server side.
@GrpcGlobalServerInterceptor
public class AuthServerInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> TOKEN_KEY =
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 token = headers.get(TOKEN_KEY);
if (token == null || !isValid(token)) {
call.close(Status.UNAUTHENTICATED
.withDescription("invalid token"), new Metadata());
return new ServerCall.Listener<>() {};
}
return next.startCall(call, headers);
}
}
Annotating with @GrpcGlobalServerInterceptor applies it to all services at once. Logging and measuring processing time can also be consolidated here by measuring timestamps before and after interceptCall.
On the client side, use a ClientInterceptor to attach the token before sending. The key point is that interceptCall must return a ClientCall, and writing to the Metadata should be done at the start timing, when sending actually begins. Extend ForwardingClientCall.SimpleForwardingClientCall and override start.
@GrpcGlobalClientInterceptor
public class TokenClientInterceptor implements ClientInterceptor {
private static final Metadata.Key<String> TOKEN_KEY =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel channel) {
ClientCall<ReqT, RespT> call = channel.newCall(method, options);
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
headers.put(TOKEN_KEY, "Bearer " + currentToken());
super.start(responseListener, headers);
}
};
}
}
By calling headers.put(...) inside start before calling super.start(...), the token is carried on the request that’s actually sent. If you want it to apply only to specific services, you can narrow the registration scope rather than going global, like @GrpcClient("user-service", interceptors = ...).
Choosing Between REST, GraphQL, and gRPC
Finally, let’s organize the criteria for making a decision. gRPC is well-suited to low-latency, high-frequency communication between internal microservices where you want to leverage schema-driven type safety. Since the .proto becomes the contract, it’s also easier to reach interface agreements between teams.
On the other hand, it’s not well-suited to externally exposed APIs called directly from a browser—REST or GraphQL is the straightforward choice there. For exposing simple CRUD, REST works; if clients need the flexibility to select the fields they need or want to aggregate multiple resources in a single request, GraphQL has the advantage.
If you just want to write type-safe HTTP calls between services, there’s also the option of going with a declarative client using OpenFeign or HTTP Interface without bringing in gRPC. If you want to keep the protocol on HTTP/1.1 or make use of existing REST assets, these are more lightweight. If you think of gRPC as something to deploy specifically for “internal communication where you want both low latency and a schema-driven approach,” you won’t go off track.
Summary
gRPC is a type-safe, lightweight RPC based on HTTP/2 and Protobuf, and by using the grpc-server-spring-boot-starter for the server and the grpc-client-spring-boot-starter for the client, you can implement it in a Spring Boot-idiomatic way with @GrpcService and @GrpcClient. Once you grasp the flow—generating code with the .proto as the contract, representing errors with Status, and cross-cutting authentication and logging through interceptors—the barrier to adoption drops considerably. We recommend first getting a single unary service running, then adding streaming and authentication from there.