gRPC APIs are fundamentally about a contract, not just a set of methods.
Imagine you’re building a system with multiple independent services, each doing its own thing. You need them to talk to each other, and you want it to be fast, efficient, and reliable. That’s where gRPC shines. It’s not just another REST API; it’s a full-blown RPC (Remote Procedure Call) framework that uses Protocol Buffers as its interface definition language (IDL) and HTTP/2 for transport.
Let’s see it in action. We’ll define a simple service to manage user profiles.
user.proto
syntax = "proto3";
package user;
message User {
string id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 2;
string email = 3;
}
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
}
This .proto file is the heart of gRPC. It’s a language-agnostic definition of your service. It specifies the messages (data structures) that can be sent and received, and the methods (RPC calls) available. syntax = "proto3"; declares we’re using Protocol Buffers version 3. package user; namespaces our definitions. message User defines the structure for a user, with id, name, and email fields. The numbers 1, 2, 3 are field tags, essential for Protocol Buffers’ binary encoding. service UserService declares our service, and rpc GetUser(GetUserRequest) returns (User); defines a method named GetUser that takes a GetUserRequest and returns a User.
Now, with this .proto file, you can generate client and server code in various languages. For example, using the protoc compiler with the Go plugin:
protoc --go_out=. --go-grpc_out=. user.proto
This command generates Go code for your UserService. You’ll get types for User, GetUserRequest, CreateUserRequest, and importantly, interfaces for your server to implement and stubs for your clients to call.
Server Implementation (Go):
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
pb "your_module_path/user" // Replace with your module path
)
type server struct {
pb.UnimplementedUserServiceServer
users map[string]*pb.User
}
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
log.Printf("Received GetUser request for ID: %v", req.GetId())
user, ok := s.users[req.GetId()]
if !ok {
return nil, fmt.Errorf("user with ID %s not found", req.GetId())
}
return user, nil
}
func (s *server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
log.Printf("Received CreateUser request: Name=%s, Email=%s", req.GetName(), req.GetEmail())
newID := fmt.Sprintf("user-%d", len(s.users)+1)
newUser := &pb.User{
Id: newID,
Name: req.GetName(),
Email: req.GetEmail(),
}
s.users[newID] = newUser
return newUser, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
userServiceServer := &server{
users: make(map[string]*pb.User),
}
pb.RegisterUserServiceServer(s, userServiceServer)
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
This server registers itself with the gRPC framework and implements the UserService interface. When a GetUser request comes in, it looks up the user in its map. For CreateUser, it generates an ID and stores the new user. The pb.UnimplementedUserServiceServer is a helpful struct that provides default behavior for all RPC methods, so you only need to override the ones you implement.
Client Implementation (Go):
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "your_module_path/user" // Replace with your module path
)
const (
address = "localhost:50051"
)
func main() {
conn, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewUserServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Create a user
createdUser, err := c.CreateUser(ctx, &pb.CreateUserRequest{Name: "Alice", Email: "alice@example.com"})
if err != nil {
log.Fatalf("could not create user: %v", err)
}
log.Printf("Created User: ID=%s, Name=%s, Email=%s", createdUser.GetId(), createdUser.GetName(), createdUser.GetEmail())
// Get the created user
user, err := c.GetUser(ctx, &pb.GetUserRequest{Id: createdUser.GetId()})
if err != nil {
log.Fatalf("could not get user: %v", err)
}
log.Printf("Retrieved User: ID=%s, Name=%s, Email=%s", user.GetId(), user.GetName(), user.GetEmail())
}
The client establishes a connection to the server, creates a stub (pb.NewUserServiceClient), and then calls the RPC methods. Notice how the client code looks very similar to the server implementation, just calling methods on the stub instead of implementing them. context.WithTimeout is crucial for managing request lifecycles and preventing runaway calls.
gRPC’s power comes from its use of Protocol Buffers for defining the API and HTTP/2 for transport. Protocol Buffers are a binary serialization format, making messages smaller and faster to serialize/deserialize than JSON or XML. HTTP/2 provides features like multiplexing (multiple requests/responses over a single connection), header compression, and server push, leading to significantly lower latency and higher throughput compared to HTTP/1.1.
When designing gRPC APIs, think about your data structures (messages) first. Keep them lean and focused. Then, define your services and methods. Consider different types of RPCs: unary (single request, single response), server streaming (single request, multiple responses), client streaming (multiple requests, single response), and bidirectional streaming (multiple requests, multiple responses). Each has its use case, but bidirectional streaming offers the most flexibility and can even emulate the other types.
The single most important thing to understand about gRPC’s performance is that it’s not just about the serialization format or the HTTP/2 protocol; it’s about the contractual nature of the API. Because the .proto file defines everything upfront, both the client and server code generators can produce highly optimized code. This also enforces strictness: if your .proto file says a field is an int32, you will send an int32. This eliminates the runtime type checking and flexibility that can bog down other RPC mechanisms.
The next step in building robust microservices with gRPC is to implement robust error handling, including custom error messages and status codes, and to explore patterns like service discovery and load balancing.