1. Understanding Unary Communication in gRPC
A unary RPC is the simplest communication pattern in gRPC.
In a unary call:
- The client sends exactly one request.
- The server processes it.
- The server returns exactly one response.
This is similar to a typical REST request–response interaction, but the internal implementation and protocol are very different because gRPC uses HTTP/2 and Protocol Buffers.
To understand this clearly, we will create:
- A client
- A Bank Service (server side)
- A method called
GetAccountBalance
The client will send an account number, and the server will return the account balance.
2. Designing the Bank Service API
Before writing Java code, we must define the contract using a .proto file. This file defines:
- Request messages
- Response messages
- Service methods
2.1 Proto File Definition
Create a file named bank_service.proto.
syntax="proto3";
package example06;
option java_multiple_files = true;
option java_package = "com.learnitweb.models.example06";
message BalanceCheckRequest {
int32 account_number = 1;
}
message AccountBalance {
int32 account_number = 1;
int32 balance = 2;
}
service BankService {
rpc GetAccountBalance(BalanceCheckRequest) returns (AccountBalance);
}
3. Generating Java Code from Proto
Run:
mvn clean compile
After a successful build, check:
target/generated-sources/protobuf/
You will see:
java/→ message classesgrpc-java/→ service stubs
Because we defined a service, gRPC generates:
BankServiceGrpc.java
This file contains:
- Server base class to extend
- Client stubs to call the service
If your IDE does not recognize these as Java sources, mark them as Generated Source Root.
4. Implementing the Service
package org.learnitweb.example06;
import com.learnitweb.models.example06.AccountBalance;
import com.learnitweb.models.example06.BalanceCheckRequest;
import com.learnitweb.models.example06.BankServiceGrpc;
import io.grpc.stub.StreamObserver;
public class BankService extends BankServiceGrpc.BankServiceImplBase {
@Override
public void getAccountBalance(BalanceCheckRequest request, StreamObserver<AccountBalance> responseObserver) {
int accountNumber = request.getAccountNumber();
AccountBalance response =
AccountBalance.newBuilder()
.setAccountNumber(accountNumber)
.setBalance(accountNumber * 10) // hardcoded logic
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
5. Why Does gRPC Use StreamObserver?
When developers first encounter gRPC in Java, one of the most surprising design choices is that service methods do not return values in the traditional way. Instead of writing a method that accepts inputs and returns a result, we write a method that receives a request object and a StreamObserver through which responses are sent. At first glance, this may feel indirect or even unnecessary, especially for simple unary calls where only one response is expected. However, this design is deeply intentional and is rooted in how gRPC views communication, concurrency, and streaming in distributed systems.
To understand why StreamObserver exists, we must temporarily set aside the mental model of local method calls and start thinking in terms of networked, asynchronous, and potentially long-lived interactions.
In everyday Java programming, a method is a closed interaction. It takes parameters, performs computation, and returns exactly one result. Once the return statement executes, the method’s lifecycle ends and the caller receives the output.
Consider a simple method:
int getBalance(int accountNumber) {
return 100;
}
This works beautifully for in-memory, synchronous logic where:
- The result is immediately available.
- Only one result is needed.
- The method does not need to stay alive after returning.
This model has served developers well for decades, but it assumes that communication is instantaneous and singular. Distributed systems, however, do not always behave this way.
Now imagine scenarios where a server must:
- Send live stock price updates.
- Stream sensor readings every second.
- Provide progress updates for a long-running job.
- Deliver chat messages as they arrive.
In such cases, a single return value is insufficient because the server may need to send many responses over time. Once a traditional method returns, it cannot “return again” later. The connection is conceptually finished.
Developers sometimes try to work around this limitation by returning lists, polling repeatedly, or building custom callback systems. However, these approaches are either inefficient or overly complex and still do not provide true streaming semantics.
This is precisely the gap that gRPC was designed to fill.
5.1 The Observer Pattern as the Core Idea
The Observer pattern allows a producer to push data to a consumer over time. Rather than asking for data once and receiving a single answer, the consumer subscribes to a flow of data.
In gRPC, the server is a producer of responses, and the client is a consumer. The StreamObserver is the mechanism through which this flow is controlled. This design matches real network communication, where messages can arrive incrementally and asynchronously.
5.2 What StreamObserver Represents
A StreamObserver<T> is essentially a response channel from the server to the client. It is not a container for data but a conduit through which data flows.
It provides three methods:
onNext
This method sends one response message to the client.
responseObserver.onNext(response);
Each invocation pushes a single message. In unary RPC, it is called once. In streaming RPC, it may be called many times.
Conceptually, this says: “Here is one piece of data.”
onCompleted
This method signals that the server has finished sending responses successfully.
responseObserver.onCompleted();
After this call, no more messages will be sent.
Conceptually, this says: “I am done sending data.”
onError
This method signals that something went wrong.
responseObserver.onError(
io.grpc.Status.INTERNAL
.withDescription("Unexpected failure")
.asRuntimeException()
);
This terminates the stream with an error status.
Conceptually, this says: “The operation failed.”
5.3 Why Unary RPC Still Uses StreamObserver
A natural question arises: if unary RPC only needs one response, why not just return it?
The answer lies in consistency and internal architecture. gRPC maintains the same interaction model for unary and streaming calls. This simplifies the framework and allows developers to move between unary and streaming APIs without learning a new paradigm.
Even a unary call is treated as a very small stream:
- One
onNextcall. - One
onCompletedcall.
This uniformity keeps the API predictable and extensible.
6. Creating the gRPC Server
Create a server class:
package org.learnitweb.common;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import org.learnitweb.example06.BankService;
public class GrpcServer {
public static void main(String[] args) throws Exception {
Server server = ServerBuilder
.forPort(6565)
.addService(new BankService())
.build();
server.start();
System.out.println("Server started on port 6565");
server.awaitTermination();
}
}
7. Testing Using Postman
Modern Postman supports gRPC testing.
8.1 Create gRPC Request
- Click New → gRPC Request
- Enter server address:
localhost:6565
HTTP/2 assumes secure connection by default.
Since we did not configure TLS:
- Disable TLS in Postman
- Ensure lock icon is off

8.2 Import Proto File
Postman needs the proto file to:
- Understand methods
- Know message structure
- Enforce type safety
Import your .proto file.
8.3 Choose Method
Select:
BankService → GetAccountBalance

8.4 Provide Request
Click Use Example Message.
Example:
{
"accountNumber": 2
}
8.5 Invoke
Click Invoke.
Response:
{
"accountNumber": 3,
"balance": 30
}

9. Understanding gRPC Status Codes
You may see:
Status: 0
Instead of HTTP 200. gRPC uses its own status codes, not HTTP status codes. Status 0 means:
OK / Success
