Featured

gRPC with Spring Boot: a detailed, practical guide to building a real client-server service

A hands-on walkthrough to build a working gRPC service in Spring Boot - with proto contracts, server code, a real Java client, and the errors you'll actually hit.

Jun 19, 20269 min read

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&#39;d a field name, and pray the API docs are up to date. It works, but it&#39;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&#39;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&#39;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:

  1. <strong><code>.proto</code> file</strong> - the contract. Defines messages (data) and services (RPCs).
  2. <strong>Generated stubs</strong> - code the Protobuf compiler generates from your <code>.proto</code>. You never hand-write this.
  3. <strong>Server implementation</strong> - your business logic, extending the generated base class.
  4. <strong>Client stub</strong> - generated client code your consumer uses to call the server like a local method.

The mental model: you&#39;re not writing HTTP handlers. You&#39;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.

xml
<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:

xml
<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&#39;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.

protobuf
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&#39;re how Protobuf identifies fields on the wire - never reuse or renumber them once a contract is live, or you&#39;ll silently corrupt data for anyone on an older client.
  • <code>ListUsers</code> returns <code>stream UserResponse</code> - that&#39;s a <strong>server-streaming</strong> RPC. The server can push multiple responses for one request, which is the kind of thing that&#39;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:

java
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&#39;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&#39;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):

properties
grpc.server.port=9090
spring.application.name=grpc-demo-server

Run 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

bash
grpcurl -plaintext localhost:9090 list

This should print <code>user.UserService</code> along with the reflection service. If it doesn&#39;t, the server either isn&#39;t running or reflection isn&#39;t enabled - add this dependency so <code>grpcurl</code> and other tools can introspect your service:

xml
<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:

bash
grpcurl -plaintext -d '{"name": "Aarav", "email": "aarav@example.com"}' \
  localhost:9090 user.UserService/CreateUser

You 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:

xml
<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&#39;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:

java
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&#39;ll see:

code
Created user with id: 1
Fetched: Priya <priya@example.com>
Expected failure: NOT_FOUND - User 9999 not found
All users:
 - [1] Priya

That <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&#39;d usually use the <strong>async stub</strong> instead, so calls don&#39;t tie up a thread:

java
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&#39;re inside a reactive or high-throughput service and can&#39;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:

xml
<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-client-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
</dependency>
properties
grpc.client.user-service.address=static://localhost:9090
grpc.client.user-service.negotiationType=plaintext
java
@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&#39;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

ConcernREST + JSONgRPC
Payload formatText (JSON)Binary (Protobuf) - smaller, faster to parse
TransportHTTP/1.1 typicallyHTTP/2 - multiplexed, header compression
ContractOften informal (OpenAPI, docs)Enforced at compile time via <code>.proto</code>
StreamingAwkward (SSE, polling)Native - client, server, and bidirectional streaming
Browser supportNativeNeeds grpc-web + proxy
Human readabilityEasy to read rawNeeds tooling (<code>grpcurl</code>, etc.) to inspect
Best fitPublic APIs, browser clientsInternal 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 &quot;just open it in a browser or curl it&quot; is a feature, not a limitation.

Common errors and fixes

  • <strong><code>UNAVAILABLE: io exception</code></strong> - the client can&#39;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&#39;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&#39;s exposing.
  • <code>grpcurl -plaintext localhost:9090 describe user.UserService</code> to see the exact method signatures live, without reading code.
  • Turn on gRPC&#39;s own logging when something&#39;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&#39;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:

  1. The consumer repo is forced to use the same <code>.proto</code> contract.
  2. If the consumer “forgets” a required field (or uses the wrong field name/type), it can’t even compile against the generated Java builders.
  3. 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:
java
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&#39;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&#39;ll build it out.