gRPC is often pitched as a performance upgrade over REST, but its real magic lies in its ability to enforce a strict contract between services, making distributed systems feel more like tightly coupled monoliths.
Let’s see it in action. Imagine two services: UserService and OrderService. UserService needs to fetch a user’s orders.
First, we define the proto file, the contract:
syntax = "proto3";
package com.example.userservice;
package com.example.orderservice;
message User {
string user_id = 1;
string name = 2;
}
message Order {
string order_id = 1;
string user_id = 2;
double amount = 3;
string status = 4;
}
message GetUserOrdersRequest {
string user_id = 1;
}
message GetUserOrdersResponse {
repeated Order orders = 1;
}
service OrderService {
rpc GetUserOrders (GetUserOrdersRequest) returns (GetUserOrdersResponse);
}
This proto file is the source of truth. We then use the protoc compiler with language-specific plugins to generate code for both Go and Java (or any other supported language).
For the UserService (the client), this generated code provides a stub with a GetUserOrders method. When UserService calls this method, it doesn’t know or care about HTTP details. It just passes a GetUserOrdersRequest object.
// UserService (Go client)
import (
"context"
"log"
pb "path/to/generated/orderservice" // Generated gRPC client code
"google.golang.org/grpc"
)
func main() {
conn, err := grpc.Dial("orderservice.internal:50051", grpc.WithInsecure()) // Connect to OrderService
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
client := pb.NewOrderServiceClient(conn)
userID := "user123"
req := &pb.GetUserOrdersRequest{UserId: userID}
res, err := client.GetUserOrders(context.Background(), req)
if err != nil {
log.Fatalf("could not get orders: %v", err)
}
log.Printf("Orders for user %s: %v", userID, res.GetOrders())
}
On the OrderService side (the server), the generated code provides an abstract OrderServiceServer interface. We implement this interface, and gRPC handles the rest: deserializing incoming requests, routing them to our implementation, and serializing the response.
// OrderService (Java server)
import io.grpc.stub.StreamObserver;
import com.example.orderservice.OrderServiceGrpc.OrderServiceImplBase;
import com.example.orderservice.GetUserOrdersRequest;
import com.example.orderservice.GetUserOrdersResponse;
import com.example.orderservice.Order;
public class OrderServiceImpl extends OrderServiceImplBase {
@Override
public void getUserOrders(GetUserOrdersRequest request, StreamObserver<GetUserOrdersResponse> responseObserver) {
String userId = request.getUserId();
// ... logic to fetch orders from a database ...
// For demonstration:
Order order1 = Order.newBuilder().setOrderId("orderA").setUserId(userId).setAmount(19.99).setStatus("SHIPPED").build();
Order order2 = Order.newBuilder().setOrderId("orderB").setUserId(userId).setAmount(5.50).setStatus("PENDING").build();
GetUserOrdersResponse response = GetUserOrdersResponse.newBuilder()
.addOrders(order1)
.addOrders(order2)
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
The gRPC framework, using Protocol Buffers, handles the serialization/deserialization to and from a compact binary format. It then transmits this over HTTP/2. HTTP/2’s multiplexing and header compression significantly boost efficiency compared to HTTP/1.1, especially for chatty microservices.
The strict contract enforced by .proto files is the core value proposition. When UserService is compiled against a specific version of the OrderService .proto definition, it knows what GetUserOrders looks like and what GetUserOrdersResponse contains. If the OrderService team later changes the Order message (e.g., adds a timestamp field), UserService won’t magically understand it. It will likely fail with a deserialization error or a schema mismatch. This forces explicit API versioning and careful coordination, preventing the subtle, hard-to-debug bugs that plague systems with looser contracts.
The most surprising thing is how much boilerplate gRPC eliminates for developers, even as it introduces a new layer of tooling. You don’t write HTTP endpoint handlers, you don’t manually serialize/deserialize JSON, and you don’t worry about content negotiation. The generated client stubs and server base classes abstract away the network communication, letting you focus on business logic.
When you’re defining your gRPC services, consider using oneof fields in your Protocol Buffer messages. This allows a message to contain one of several possible fields, but only one at a time. It’s a powerful way to represent variations in data within a single message type, leading to more efficient serialization and clearer API design, especially for polymorphic data structures.
The next hurdle is managing service discovery and load balancing in a gRPC ecosystem.