gRPC’s greatest strength, its strict schema enforcement, is also the source of its most unexpected friction.
Let’s see gRPC in action. Imagine we have a simple Greeter service.
syntax = "proto3";
package greeter;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
This .proto file defines the contract. We’ll use protoc (the protobuf compiler) with the Go plugin to generate Go code.
protoc --go_out=. --go-grpc_out=. --proto_path=. greeter.proto
This produces greeter.pb.go and greeter_grpc.pb.go. The first contains the Go struct definitions for HelloRequest and HelloReply, and the second contains the Go interfaces for our Greeter server and client, along with boilerplate for client stubbing and server registration.
Here’s a minimal server implementation:
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
pb "example.com/greeter" // Assuming generated code is in this module path
)
type server struct {
pb.UnimplementedGreeterServer
}
func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", req.GetName())
return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterGreeterServer(s, &server{})
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
And here’s a client:
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "example.com/greeter"
)
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.NewGreeterClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "World"})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetMessage())
}
When you run both, you’ll see the client log "Greeting: Hello World" and the server log "Received: World". This is gRPC working: the .proto file is the single source of truth, and the generated code handles serialization, deserialization, and network transport.
The mental model for gRPC is built around this contract. Services are collections of RPC methods. Each RPC method has a request and a response message type, both defined in the .proto file. On the server side, you implement an interface that mirrors the service definition. On the client side, you use a generated client stub to call these methods. The underlying mechanism uses HTTP/2 for transport, Protocol Buffers for serialization, and context.Context for managing request-scoped values and cancellation.
The key to managing gRPC services in production is understanding the implications of schema evolution. When you change a .proto file, you must regenerate the Go code and redeploy both your clients and servers. This is a coordinated effort. The critical rule is to maintain backward compatibility: never remove a field, and never change the field number of an existing field. You can add new optional fields, or add new RPC methods. If you need to make a breaking change, you should version your service (e.g., GreeterV2) and have clients and servers migrate over time.
When you define a message field in protobuf, you must assign it a unique integer tag. This tag is what the wire format uses to identify fields, not the field name. If you change the tag number of an existing field, you break backward compatibility because old clients sending messages with the old tag will be interpreted by new servers as a different field, or worse, ignored. Similarly, removing a field breaks compatibility because old clients won’t send it, and new servers might expect it. The generated Go code uses these tags internally for serialization and deserialization.