The problem gRPC actually solves
Picture two services talking over REST. Every call means: serialize to JSON, send over HTTP/1.1, parse JSON on the other side, hope nobody typo'd a field name, and pray the API docs are up to date. It works, but it's chatty, loosely typed, and slow at scale. gRPC flips this. You write a <strong>contract first</strong> (a <code>.proto</code> file), generate strongly-typed code for both client and server from it, and talk over HTTP/2 with binary Protocol Buffers instead of text JSON. The result: smaller payloads, faster calls, and a contract that breaks the build at compile time instead of breaking production at 2 AM. This guide builds one real service end to end - a <code>UserService</code> - with a Spring Boot server and a working Java client, the same way you'd actually do it on a project.
Keep the project small
- One <code>.proto</code> contract
- One Spring Boot gRPC server
- One standalone Java client (separate process, like a real consumer would be)
That's enough surface to learn the full request lifecycle without getting lost in unrelated complexity. <strong>Prerequisites</strong>
- JDK 17+
- Maven (the examples use Maven; Gradle works the same way conceptually)
- Basic Spring Boot familiarity
- <code>grpcurl</code> installed (optional, but great for debugging - think of it as <code>curl</code> for gRPC)
Step A - Understand the moving parts before writing code
gRPC has four pieces that always show up together:
- <strong><code>.proto</code> file</strong> - the contract. Defines messages (data) and services (RPCs).
- <strong>Generated stubs</strong> - code the Protobuf compiler generates from your <code>.proto</code>. You never hand-write this.
- <strong>Server implementation</strong> - your business logic, extending the generated base class.
- <strong>Client stub</strong> - generated client code your consumer uses to call the server like a local method.
The mental model: you're not writing HTTP handlers. You're implementing an interface that the compiler generated for you, and gRPC handles networking underneath.
Step B - Set up the Spring Boot server project
Create a new Maven project. The dependency that does the heavy lifting is <code>grpc-spring-boot-starter</code> from the <code>net.devh</code> group - it auto-configures the gRPC server inside Spring Boot, the same way <code>spring-boot-starter-web</code> auto-configures Tomcat.
<dependencies>
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.65.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.65.1</version>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>2.1.1</version>
</dependency>
</dependencies>You also need the Protobuf compiler wired into your build, so Maven generates Java code from <code>.proto</code> files automatically on every build:
<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.1:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.65.1:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>This <code>os-maven-plugin</code> + <code>protobuf-maven-plugin</code> combo is the part people skip and then can't figure out why no Java classes got generated. Without it, your <code>.proto</code> file just sits there as a text file doing nothing.
Step C - Write the contract (.proto file)
Place this at <code>src/main/proto/user.proto</code>. This single file is the source of truth both the server and client will be generated from.
syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.example.grpcdemo.grpc";
option java_outer_classname = "UserProto";
package user;
service UserService {
rpc GetUser (UserRequest) returns (UserResponse);
rpc CreateUser (CreateUserRequest) returns (UserResponse);
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
}
message UserRequest {
int32 id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message ListUsersRequest {
int32 page_size = 1;
}
message UserResponse {
int32 id = 1;
string name = 2;
string email = 3;
}A few things worth noticing here, because they trip people up later:
- The numbers (<code>= 1</code>, <code>= 2</code>) are <strong>field tags</strong>, not default values. They're how Protobuf identifies fields on the wire - never reuse or renumber them once a contract is live, or you'll silently corrupt data for anyone on an older client.
- <code>ListUsers</code> returns <code>stream UserResponse</code> - that's a <strong>server-streaming</strong> RPC. The server can push multiple responses for one request, which is the kind of thing that's awkward in REST and trivial in gRPC.
- <code>java_multiple_files = true</code> means each message and service gets its own generated <code>.java</code> file instead of being nested inside one giant outer class. Almost always what you want.
Step D - Implement the server
Run <code>mvn compile</code> once first - this generates <code>UserServiceGrpc</code>, <code>UserRequest</code>, <code>UserResponse</code>, etc. into <code>target/generated-sources</code>. Now implement the service:
package com.example.grpcdemo.server;
import com.example.grpcdemo.grpc.*;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.server.service.GrpcService;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@GrpcService
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
private final ConcurrentHashMap<Integer, UserResponse> store = new ConcurrentHashMap<>();
private final AtomicInteger idCounter = new AtomicInteger(1);
@Override
public void getUser(UserRequest request, StreamObserver<UserResponse> responseObserver) {
UserResponse user = store.get(request.getId());
if (user == null) {
responseObserver.onError(
io.grpc.Status.NOT_FOUND
.withDescription("User " + request.getId() + " not found")
.asRuntimeException()
);
return;
}
responseObserver.onNext(user);
responseObserver.onCompleted();
}
@Override
public void createUser(CreateUserRequest request, StreamObserver<UserResponse> responseObserver) {
int id = idCounter.getAndIncrement();
UserResponse user = UserResponse.newBuilder()
.setId(id)
.setName(request.getName())
.setEmail(request.getEmail())
.build();
store.put(id, user);
responseObserver.onNext(user);
responseObserver.onCompleted();
}
@Override
public void listUsers(ListUsersRequest request, StreamObserver<UserResponse> responseObserver) {
store.values().forEach(responseObserver::onNext);
responseObserver.onCompleted();
}
}The <code>@GrpcService</code> annotation is doing what <code>@RestController</code> does for REST - it tells the <code>grpc-spring-boot-starter</code> to register this bean as a live gRPC service the moment the application context starts. Notice the pattern in every method: <code>onNext()</code> to send a value, <code>onCompleted()</code> to signal you're done, <code>onError()</code> if something went wrong. For the streaming <code>listUsers</code> method, you can call <code>onNext()</code> as many times as you want before <code>onCompleted()</code> - that's the entire mechanism behind server streaming. Configure the gRPC port in <code>application.properties</code> (separate from any HTTP port, since gRPC runs on its own port over HTTP/2):
grpc.server.port=9090
spring.application.name=grpc-demo-serverRun it with <code>mvn spring-boot:run</code>. You now have a live gRPC server listening on <code>9090</code>.
Sanity check the server before writing a client
grpcurl -plaintext localhost:9090 listThis should print <code>user.UserService</code> along with the reflection service. If it doesn't, the server either isn't running or reflection isn't enabled - add this dependency so <code>grpcurl</code> and other tools can introspect your service:
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-server-spring-boot-starter</artifactId>
</dependency>(Reflection comes bundled with the starter by default in recent versions - if <code>list</code> comes back empty, check <code>grpc.server.reflection-service-enabled=true</code> is set.) Test the actual call:
grpcurl -plaintext -d '{"name": "Aarav", "email": "aarav@example.com"}' \
localhost:9090 user.UserService/CreateUserYou should get back a JSON-shaped response with an assigned <code>id</code>. The server is real and working - now build the client.
Step E - Build a standalone Java client
This is the part most tutorials skip or fake with the same project. A real client is a <strong>separate consumer</strong> - a different service, a different team, possibly a different repo entirely - that only has the <code>.proto</code> contract and needs to generate its own stub. Create a new Maven project (<code>grpc-demo-client</code>) with the same <code>protobuf-maven-plugin</code> setup as the server, but only the client-relevant dependencies:
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>1.65.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>1.65.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>1.65.1</version>
</dependency>
</dependencies>Copy the exact same <code>user.proto</code> into this project's <code>src/main/proto/</code>. This is the actual contract enforcement in action: both sides compile against the same file, so any mismatch is caught at build time, not at runtime in production. Now the client code itself:
package com.example.grpcdemo.client;
import com.example.grpcdemo.grpc.*;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import java.util.Iterator;
public class UserClient {
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext()
.build();
UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel);
// 1. Create a user
CreateUserRequest createRequest = CreateUserRequest.newBuilder()
.setName("Priya")
.setEmail("priya@example.com")
.build();
UserResponse created = stub.createUser(createRequest);
System.out.println("Created user with id: " + created.getId());
// 2. Fetch the same user back
UserRequest getRequest = UserRequest.newBuilder()
.setId(created.getId())
.build();
try {
UserResponse fetched = stub.getUser(getRequest);
System.out.println("Fetched: " + fetched.getName() + " <" + fetched.getEmail() + ">");
} catch (StatusRuntimeException e) {
System.out.println("Call failed: " + e.getStatus());
}
// 3. Try fetching something that doesn't exist - exercises the error path
try {
stub.getUser(UserRequest.newBuilder().setId(9999).build());
} catch (StatusRuntimeException e) {
System.out.println("Expected failure: " + e.getStatus().getCode()
+ " - " + e.getStatus().getDescription());
}
// 4. Server streaming - list every user, one item at a time
Iterator<UserResponse> allUsers = stub.listUsers(
ListUsersRequest.newBuilder().setPageSize(10).build()
);
System.out.println("All users:");
while (allUsers.hasNext()) {
UserResponse u = allUsers.next();
System.out.println(" - [" + u.getId() + "] " + u.getName());
}
channel.shutdown();
}
}Run the server first, then run this client as a plain Java application. You'll see:
Created user with id: 1
Fetched: Priya <priya@example.com>
Expected failure: NOT_FOUND - User 9999 not found
All users:
- [1] PriyaThat <code>Expected failure</code> line is the important one. It proves the client is correctly receiving a typed <code>Status</code> object across the wire - not a generic exception, not an HTTP status code you have to interpret, but the exact <code>NOT_FOUND</code> semantic the server set. This is the strongly-typed contract paying off in practice.
Blocking vs async stub - which to use when
The client above used <code>UserServiceGrpc.newBlockingStub()</code> - each call blocks the calling thread until a response comes back, which is fine for scripts, CLI tools, and simple request/response flows. For a real production client inside another Spring Boot service, you'd usually use the <strong>async stub</strong> instead, so calls don't tie up a thread:
UserServiceGrpc.UserServiceStub asyncStub = UserServiceGrpc.newStub(channel);
asyncStub.getUser(getRequest, new StreamObserver<UserResponse>() {
@Override
public void onNext(UserResponse response) {
System.out.println("Got: " + response.getName());
}
@Override
public void onError(Throwable t) {
System.out.println("Error: " + t.getMessage());
}
@Override
public void onCompleted() {
System.out.println("Stream finished");
}
});Same contract, same generated classes - just a different calling convention. Pick blocking for simplicity, async when you're inside a reactive or high-throughput service and can't afford to block threads.
Wiring the client into another Spring Boot app (the realistic setup)
If the consumer is itself a Spring Boot service rather than a standalone <code>main()</code>, use <code>grpc-client-spring-boot-starter</code> instead of wiring the channel by hand:
<dependency>
<groupId>net.devh</groupId>
<artifactId>grpc-client-spring-boot-starter</artifactId>
<version>3.1.0.RELEASE</version>
</dependency>grpc.client.user-service.address=static://localhost:9090
grpc.client.user-service.negotiationType=plaintext@Service
public class UserClientService {
@GrpcClient("user-service")
private UserServiceGrpc.UserServiceBlockingStub userStub;
public UserResponse fetchUser(int id) {
return userStub.getUser(UserRequest.newBuilder().setId(id).build());
}
}This is the version you'd actually ship: Spring manages the channel lifecycle, retries, and reconnection for you, the same way it manages a <code>RestTemplate</code> or <code>WebClient</code> bean.
gRPC vs REST - when to actually pick it
| Concern | REST + JSON | gRPC |
|---|---|---|
| Payload format | Text (JSON) | Binary (Protobuf) - smaller, faster to parse |
| Transport | HTTP/1.1 typically | HTTP/2 - multiplexed, header compression |
| Contract | Often informal (OpenAPI, docs) | Enforced at compile time via <code>.proto</code> |
| Streaming | Awkward (SSE, polling) | Native - client, server, and bidirectional streaming |
| Browser support | Native | Needs grpc-web + proxy |
| Human readability | Easy to read raw | Needs tooling (<code>grpcurl</code>, etc.) to inspect |
| Best fit | Public APIs, browser clients | Internal service-to-service calls, polyglot microservices |
The honest rule of thumb: gRPC wins for internal microservice communication where both ends are services you control and performance matters. REST still wins for anything browser-facing or for public APIs where "just open it in a browser or curl it" is a feature, not a limitation.
Common errors and fixes
- <strong><code>UNAVAILABLE: io exception</code></strong> - the client can't reach the server. Check the port matches <code>grpc.server.port</code>, and confirm the server actually started (look for the <code>gRPC Server started</code> log line).
- <strong>No generated classes / <code>UserServiceGrpc</code> not found</strong> - the <code>protobuf-maven-plugin</code> didn't run. Run <code>mvn clean compile</code> explicitly and check <code>target/generated-sources/protobuf</code> exists.
- <strong><code>UNIMPLEMENTED: Method not found</code></strong> - client and server are compiled from different <code>.proto</code> files (different package name or service name). Diff the two <code>.proto</code> files line by line.
- <strong>Works with <code>grpcurl</code> but not the Java client</strong> - almost always a missing <code>.usePlaintext()</code> call. Without TLS configured, the channel defaults to expecting TLS and the plaintext server will hang or reject it.
- <strong>Field always comes back as default value (0 / empty string)</strong> - field tag mismatch between client and server <code>.proto</code> versions. This is exactly why field tags must never be reused.
Debugging tips (practical)
- <code>grpcurl -plaintext localhost:9090 list</code> to confirm what services the server thinks it's exposing.
- <code>grpcurl -plaintext localhost:9090 describe user.UserService</code> to see the exact method signatures live, without reading code.
- Turn on gRPC's own logging when something's wrong at the wire level: <code>-Djava.util.logging.config.file=logging.properties</code> with <code>io.grpc.level = FINE</code>.
- If a streaming call hangs forever, check the server actually called <code>onCompleted()</code> - a missed <code>onCompleted()</code> is the single most common cause of a client waiting forever on a stream.
Next steps and experiments
- Add <strong>client streaming</strong> (client sends a stream, server returns one response) - useful for batch uploads.
- Add <strong>bidirectional streaming</strong> for something like a chat service.
- Swap <code>usePlaintext()</code> for real TLS using <code>grpc-netty-shaded</code> with certificates - required before this ever sees production traffic.
- Add interceptors for auth (an <code>Authorization</code> metadata header checked server-side) instead of passing credentials inside messages.
- Generate the same client stub in Python or Go from the identical <code>.proto</code> file, and watch the same contract work across languages - this is where gRPC's polyglot story actually shows up.
A fake-but-real “client required something” restore: missing data causes REST-time failures, gRPC fixes it
A common production incident looks like this:
- A frontend/service calls a REST endpoint like <code>POST /users</code>.
- The JSON payload is missing a required field (or it’s named slightly differently), but the server either:
- accepts it and later fails deep in business logic, or
- returns a vague 400/500 with a message that nobody can reliably map back to the exact contract violation.
- Result: engineers spend time chasing runtime payload shape issues, and deploys become scary.
With gRPC, the same class of problem becomes a <strong>compile-time</strong> issue:
- The consumer repo is forced to use the same <code>.proto</code> contract.
- If the consumer “forgets” a required field (or uses the wrong field name/type), it can’t even compile against the generated Java builders.
- Even if a value is present but invalid (e.g., empty email), you can make that deterministic at runtime with proper gRPC status codes.
Practical example using this guide’s contract:
- In <code>CreateUserRequest</code>, the consumer must set <code>name</code> and <code>email</code>.
- In Java, that means the generated builder must be populated:
CreateUserRequest createRequest = CreateUserRequest.newBuilder()
.setName("Priya")
.setEmail("priya@example.com")
.build();If a developer tries to send something like <code>setEmailAddress(...)</code> (wrong field) or omits <code>setEmail(...)</code>, the code won’t match the generated API. The failure happens before the client ever runs. What about “missing at runtime” (e.g., database lookup finds no user)? That’s exactly where gRPC’s typed errors help:
- The server returns <code>Status.NOT_FOUND</code> via <code>responseObserver.onError(Status.NOT_FOUND...asRuntimeException())</code>.
- The client catches <code>StatusRuntimeException</code> and can branch on <code>e.getStatus().getCode()</code>.
So instead of an ambiguous REST error, you get a predictable, strongly-typed contract: either the request shape matches the proto, or the service returns a well-defined gRPC status.
Final checklist before calling this done
- <code>.proto</code> file compiles cleanly on both server and client projects
- Server responds correctly to <code>grpcurl</code> for every RPC method
- Client successfully calls <code>CreateUser</code>, <code>GetUser</code>, and the streaming <code>ListUsers</code>
- Error path tested - a <code>NOT_FOUND</code> or similar status comes back typed, not as a generic crash
- Port and <code>usePlaintext()</code>/TLS settings match between client and server
--- This guide is intentionally hands-on: a real contract, a real server, and a real separate client process talking to it - the same shape you'd use on an actual project. Want the same flow extended with TLS, auth interceptors, or a Python client generated from the same <code>.proto</code>? Tell me which piece and I'll build it out.