Go gRPC: Protocol Buffers and RPC

gRPC is Google's open-source RPC framework built on HTTP/2, using Protocol Buffers (protobuf) as its interface definition language. Unlike REST APIs that send human-readable JSON over HTTP/1.1, gRPC...

Key Insights

  • gRPC with Protocol Buffers provides type-safe, high-performance RPC communication that’s significantly faster than REST/JSON for service-to-service calls
  • Protocol Buffer definitions serve as both documentation and code generation source, eliminating the drift between API specs and implementation
  • Go’s native support for gRPC makes it ideal for building microservices that need low latency and efficient serialization

Why gRPC and Protocol Buffers Matter

gRPC is Google’s open-source RPC framework built on HTTP/2, using Protocol Buffers (protobuf) as its interface definition language. Unlike REST APIs that send human-readable JSON over HTTP/1.1, gRPC transmits compact binary data over persistent connections with built-in streaming support.

The combination delivers measurable advantages: protobuf serialization is 3-6x faster than JSON, payloads are typically 30% smaller, and HTTP/2’s multiplexing eliminates connection overhead. For microservices architectures where services communicate thousands of times per second, these improvements compound dramatically.

More importantly, protobuf definitions create a contract between services. You define your API once, generate client and server code in multiple languages, and get compile-time type safety. This prevents the runtime errors common with loosely-typed REST APIs.

Setting Up Your Go gRPC Project

Start by initializing a Go module and installing the necessary tooling:

mkdir grpc-user-service && cd grpc-user-service
go mod init github.com/yourusername/grpc-user-service

# Install Protocol Buffer compiler plugins for Go
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# Install gRPC package
go get google.golang.org/grpc
go get google.golang.org/protobuf

You’ll also need the protoc compiler itself. On macOS: brew install protobuf. On Linux: download from the GitHub releases.

Structure your project like this:

grpc-user-service/
├── proto/
│   └── user.proto
├── server/
│   └── main.go
├── client/
│   └── main.go
└── go.mod

Defining Your Protocol Buffer Schema

Create proto/user.proto to define your messages and service:

syntax = "proto3";

package user;

option go_package = "github.com/yourusername/grpc-user-service/proto";

message User {
  string id = 1;
  string email = 2;
  string name = 3;
  int64 created_at = 4;
}

message CreateUserRequest {
  string email = 1;
  string name = 2;
}

message CreateUserResponse {
  User user = 1;
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

The syntax is straightforward: messages define data structures, fields have types and unique numbers (used in the binary encoding), and services declare RPC methods. The go_package option tells the compiler where to generate Go code.

Generate the Go code:

protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/user.proto

This creates proto/user.pb.go (message types) and proto/user_grpc.pb.go (service interfaces). You’ll see generated structs like User, CreateUserRequest, and an interface UserServiceServer that your server must implement.

Implementing the gRPC Server

Create server/main.go to implement the service:

package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    
    pb "github.com/yourusername/grpc-user-service/proto"
    "github.com/google/uuid"
)

type userServer struct {
    pb.UnimplementedUserServiceServer
    users map[string]*pb.User
}

func newUserServer() *userServer {
    return &userServer{
        users: make(map[string]*pb.User),
    }
}

func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
    if req.Email == "" || req.Name == "" {
        return nil, status.Error(codes.InvalidArgument, "email and name are required")
    }

    user := &pb.User{
        Id:        uuid.New().String(),
        Email:     req.Email,
        Name:      req.Name,
        CreatedAt: time.Now().Unix(),
    }

    s.users[user.Id] = user

    return &pb.CreateUserResponse{User: user}, nil
}

func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    user, exists := s.users[req.Id]
    if !exists {
        return nil, status.Error(codes.NotFound, "user not found")
    }

    return &pb.GetUserResponse{User: user}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    grpcServer := grpc.NewServer()
    pb.RegisterUserServiceServer(grpcServer, newUserServer())

    fmt.Println("gRPC server listening on :50051")
    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Key points: embed UnimplementedUserServiceServer for forward compatibility, use status.Error() for proper gRPC error codes, and validate inputs. The server uses an in-memory map for simplicity—replace this with a database in production.

Building the gRPC Client

Create client/main.go:

package main

import (
    "context"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    
    pb "github.com/yourusername/grpc-user-service/proto"
)

func main() {
    conn, err := grpc.Dial("localhost:50051", 
        grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatalf("failed to connect: %v", err)
    }
    defer conn.Close()

    client := pb.NewUserServiceClient(conn)

    // Create a user with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    createResp, err := client.CreateUser(ctx, &pb.CreateUserRequest{
        Email: "alice@example.com",
        Name:  "Alice Johnson",
    })
    if err != nil {
        log.Fatalf("CreateUser failed: %v", err)
    }

    log.Printf("Created user: %s (ID: %s)", createResp.User.Name, createResp.User.Id)

    // Retrieve the user
    getResp, err := client.GetUser(ctx, &pb.GetUserRequest{
        Id: createResp.User.Id,
    })
    if err != nil {
        log.Fatalf("GetUser failed: %v", err)
    }

    log.Printf("Retrieved user: %+v", getResp.User)
}

Always use contexts with timeouts for production clients. This prevents hanging requests and allows proper cancellation propagation.

Advanced Patterns and Best Practices

Interceptors for Cross-Cutting Concerns

Interceptors work like middleware. Here’s a logging interceptor:

func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    start := time.Now()
    
    resp, err := handler(ctx, req)
    
    log.Printf("method=%s duration=%s error=%v", 
        info.FullMethod, 
        time.Since(start), 
        err)
    
    return resp, err
}

// Add to server creation
grpcServer := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
)

Context Deadlines and Cancellation

Always respect context deadlines in your handlers:

func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
    // Check if context is already cancelled
    if ctx.Err() == context.Canceled {
        return nil, status.Error(codes.Canceled, "request cancelled")
    }
    
    // Simulate database operation
    select {
    case <-time.After(100 * time.Millisecond):
        // Operation completed
    case <-ctx.Done():
        return nil, status.Error(codes.DeadlineExceeded, "operation timed out")
    }
    
    // ... rest of implementation
}

Testing gRPC Services

Use the bufconn package to test without network I/O:

func TestCreateUser(t *testing.T) {
    lis := bufconn.Listen(1024 * 1024)
    s := grpc.NewServer()
    pb.RegisterUserServiceServer(s, newUserServer())
    
    go func() {
        if err := s.Serve(lis); err != nil {
            log.Fatalf("Server exited with error: %v", err)
        }
    }()
    defer s.Stop()

    dialer := func(context.Context, string) (net.Conn, error) {
        return lis.Dial()
    }

    conn, err := grpc.DialContext(context.Background(), "bufnet",
        grpc.WithContextDialer(dialer),
        grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to dial: %v", err)
    }
    defer conn.Close()

    client := pb.NewUserServiceClient(conn)
    
    resp, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
        Email: "test@example.com",
        Name:  "Test User",
    })
    
    if err != nil {
        t.Fatalf("CreateUser failed: %v", err)
    }
    
    if resp.User.Email != "test@example.com" {
        t.Errorf("Expected email test@example.com, got %s", resp.User.Email)
    }
}

Moving to Production

For production deployments, add TLS encryption, implement health checks, and use connection pooling. Enable reflection for debugging:

import "google.golang.org/grpc/reflection"

reflection.Register(grpcServer)

This allows tools like grpcurl to introspect your services without proto files.

Consider using gRPC gateway to expose REST endpoints alongside gRPC, giving you the best of both worlds for browser clients while maintaining efficient service-to-service communication.

gRPC shines in microservices architectures where you control both client and server. The type safety, performance, and tooling support make it the right choice for internal APIs. For public APIs consumed by diverse clients, REST remains more accessible, but for backend services, gRPC delivers measurable improvements in latency, throughput, and developer productivity.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.