Skip to main content
โšก Calmops

gRPC API Design: High-Performance APIs

gRPC API Design: High-Performance APIs

gRPC is a high-performance RPC framework that uses HTTP/2 for transport and Protocol Buffers for serialization. This guide covers everything you need to design efficient gRPC APIs.

What is gRPC?

gRPC (Google Remote Procedure Call) is an open-source RPC framework that uses:

  • Protocol Buffers for efficient serialization
  • HTTP/2 for multiplexed connections
  • Code generation for strongly-typed clients

Why gRPC?

  • Performance: 7-10x faster than REST
  • Streaming: Built-in support for bi-directional streaming
  • Code Generation: Type-safe contracts
  • Cross-language: Clients in many languages

Protocol Buffers Basics

Defining Messages

syntax = "proto3";

package myapp;

// Simple message
message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
  bool active = 5;
}

// Nested messages
message Order {
  string id = 1;
  User customer = 2;
  repeated Item items = 3;
  Status status = 4;
  google.protobuf.Timestamp created_at = 5;
}

message Item {
  string product_id = 1;
  string name = 2;
  int32 quantity = 3;
  double price = 4;
}

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_PENDING = 1;
  STATUS_COMPLETED = 2;
  STATUS_CANCELLED = 3;
}

Scalar Types

Proto Type Go Type Python Type Description
double float64 float 64-bit float
float float32 float 32-bit float
int32 int32 int Variable-length int
int64 int64 int 64-bit int
string string str UTF-8 string
bool bool bool Boolean
bytes []byte bytes Byte sequence

Repeated, Optional, and Maps

// Arrays
message User {
  repeated string roles = 1;      // []string
  repeated Order orders = 2;       // []Order
}

// Optional fields (Proto3)
message User {
  // New optional syntax (Proto3.15+)
  optional string nickname = 1;
  // Or use wrapper types
  google.protobuf.StringValue nickname = 1;
}

// Maps
message UserPreferences {
  map<string, string> settings = 1;
  map<string, int32> limits = 2;
}

gRPC Service Definition

Simple RPC

// Unary RPC - like REST endpoints
service UserService {
  // Get a user by ID
  rpc GetUser (GetUserRequest) returns (User);
  
  // Create a new user
  rpc CreateUser (CreateUserRequest) returns (User);
  
  // Update a user
  rpc UpdateUser (UpdateUserRequest) returns (User);
  
  // Delete a user
  rpc DeleteUser (DeleteUserRequest) returns (Empty);
  
  // List users
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}

message GetUserRequest {
  string id = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
  int32 age = 3;
}

message UpdateUserRequest {
  string id = 1;
  User user = 2;
}

message DeleteUserRequest {
  string id = 1;
}

message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;
}

// Empty response
message Empty {}

Server Streaming

// Server streams responses
service NotificationService {
  rpc StreamNotifications (NotificationRequest) returns (stream Notification);
}

message NotificationRequest {
  string user_id = 1;
}

message Notification {
  string id = 1;
  string message = 2;
  string type = 3;
  google.protobuf.Timestamp timestamp = 4;
}

Client Streaming

// Client streams requests
service UploadService {
  rpc Upload stream UploadRequest returns (UploadResponse);
}

message UploadRequest {
  string filename = 1;
  bytes chunk = 2;
  int32 chunk_index = 3;
}

message UploadResponse {
  string file_id = 1;
  int64 size = 2;
  bool success = 3;
}

Bi-directional Streaming

// Both client and server stream
service ChatService {
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}

message ChatMessage {
  string id = 1;
  string user_id = 2;
  string content = 3;
  google.protobuf.Timestamp timestamp = 4;
}

Code Generation

Setup

# Install protoc and Go plugin
apt-get install protobuf-compiler
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

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

Generated Go Code

// Generated interface
type UserServiceClient interface {
    GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
    CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
    UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.Option) (*User, error)
    DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*Empty, error)
    ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
}

// Server implementation
type UserServiceServer interface {
    GetUser(context.Context, *GetUserRequest) (*User, error)
    CreateUser(context.Context, *CreateUserRequest) (*User, error)
    UpdateUser(context.Context, *UpdateUserRequest) (*User, error)
    DeleteUser(context.Context, *DeleteUserRequest) (*Empty, error)
    ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
    mustEmbedUnimplementedUserServiceServer()
}

Server Implementation

package main

import (
    "context"
    "net"
    
    "google.golang.org/grpc"
    "google.golang.org/protobuf/types/known/timestamppb"
    "myapp/proto/user"
)

type userService struct {
    user.UnimplementedUserServiceServer
    db *Database
}

func (s *userService) GetUser(ctx context.Context, req *user.GetUserRequest) (*user.User, error) {
    u, err := s.db.GetUser(ctx, req.Id)
    if err != nil {
        return nil, err
    }
    
    return &user.User{
        Id:        u.ID,
        Name:      u.Name,
        Email:     u.Email,
        Age:       int32(u.Age),
        Active:    u.Active,
        CreatedAt: timestamppb.New(u.CreatedAt),
    }, nil
}

func (s *userService) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.User, error) {
    u := &User{
        Name:  req.Name,
        Email: req.Email,
        Age:   int(req.Age),
    }
    
    created, err := s.db.CreateUser(ctx, u)
    if err != nil {
        return nil, err
    }
    
    return &user.User{
        Id:        created.ID,
        Name:      created.Name,
        Email:     created.Email,
        Age:       int32(created.Age),
        Active:    true,
        CreatedAt: timestamppb.New(created.CreatedAt),
    }, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    
    grpcServer := grpc.NewServer()
    user.RegisterUserServiceServer(grpcServer, &userService{db: db})
    
    if err := grpcServer.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

Client Implementation

package main

import (
    "context"
    "log"
    
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "myapp/proto/user"
)

func main() {
    conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    
    client := user.NewUserServiceClient(conn)
    
    // Call GetUser
    ctx := context.Background()
    user, err := client.GetUser(ctx, &user.GetUserRequest{Id: "123"})
    if err != nil {
        log.Fatalf("could not get user: %v", err)
    }
    
    log.Printf("User: %s <%s>", user.Name, user.Email)
}

Error Handling

// Define error codes
enum ErrorCode {
    ERROR_CODE_UNSPECIFIED = 0;
    ERROR_CODE_NOT_FOUND = 1;
    ERROR_CODE_INVALID_INPUT = 2;
    ERROR_CODE_UNAUTHORIZED = 3;
    ERROR_CODE_INTERNAL = 4;
}

message Error {
    ErrorCode code = 1;
    string message = 2;
    repeated string details = 3;
}
import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// Server-side error
func (s *userService) GetUser(ctx context.Context, req *user.GetUserRequest) (*user.User, error) {
    user, err := s.db.GetUser(req.Id)
    if err == ErrNotFound {
        return nil, status.Error(codes.NotFound, "user not found")
    }
    if err == ErrInvalidInput {
        return nil, status.Error(codes.InvalidArgument, "invalid user ID")
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "internal error")
    }
    return user, nil
}

// Client-side handling
resp, err := client.GetUser(ctx, &user.GetUserRequest{Id: "123"})
if err != nil {
    st, ok := status.FromError(err)
    if !ok {
        return err // Not a gRPC error
    }
    
    switch st.Code() {
    case codes.NotFound:
        return fmt.Errorf("user not found: %s", st.Message())
    case codes.InvalidArgument:
        return fmt.Errorf("invalid input: %s", st.Message())
    case codes.Internal:
        return fmt.Errorf("server error: %s", st.Message())
    default:
        return fmt.Errorf("unknown error: %s", st.Message())
    }
}

Metadata and Authentication

Metadata

// Server - read metadata
func (s *userService) GetUser(ctx context.Context, req *user.GetUserRequest) (*user.User, error) {
    md, ok := metadata.FromIncomingContext(ctx)
    if ok {
        if token := md.Get("authorization"); len(token) > 0 {
            log.Printf("Token: %s", token[0])
        }
    }
    
    userID := ctx.Value("userID").(string)
    // Use userID for authorization
}

// Client - send metadata
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs(
    "authorization", "Bearer "+token,
))
resp, err := client.GetUser(ctx, req)

Authentication Interceptor

// Server interceptor
func authInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // Extract token from metadata
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }
    
    token := md.Get("authorization")
    if len(token) == 0 {
        return nil, status.Error(codes.Unauthenticated, "missing token")
    }
    
    // Validate token
    userID, err := validateToken(token[0])
    if err != nil {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }
    
    // Add user ID to context
    ctx = context.WithValue(ctx, "userID", userID)
    
    return handler(ctx, req)
}

func main() {
    server := grpc.NewServer(
        grpc.UnaryInterceptor(authInterceptor),
    )
}

Streaming Implementation

Server Streaming

// Server
func (s *notificationService) StreamNotifications(req *user.NotificationRequest, stream user.NotificationService_StreamNotificationsServer) error {
    userID := req.UserId
    
    // Subscribe to notifications
    channel := s.notifier.Subscribe(userID)
    defer s.notifier.Unsubscribe(userID, channel)
    
    for {
        select {
        case notification := <-channel:
            if err := stream.Send(notification); err != nil {
                return err
            }
        case <-stream.Context().Done():
            return stream.Context().Err()
        }
    }
}

// Client
stream, err := client.StreamNotifications(context.Background(), &user.NotificationRequest{UserId: "123"})
if err != nil {
    log.Fatal(err)
}

for {
    notification, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Notification: %s\n", notification.Message)
}

Bi-directional Streaming

// Server
func (s *chatService) Chat(stream user.ChatService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        
        // Process message
        response := &user.ChatMessage{
            Id:        uuid.New().String(),
            UserId:    msg.UserId,
            Content:   "Echo: " + msg.Content,
            Timestamp: timestamppb.Now(),
        }
        
        if err := stream.Send(response); err != nil {
            return err
        }
    }
}

// Client
stream, err := client.Chat(context.Background())
if err != nil {
    log.Fatal(err)
}

// Send messages
go func() {
    for {
        stream.Send(&user.ChatMessage{
            UserId:  "user123",
            Content: "Hello!",
        })
        time.Sleep(time.Second)
    }
}()

// Receive messages
go func() {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("Received: %s\n", msg.Content)
    }
}()

stream.CloseSend()
// Wait for completion
<-stream.Context().Done()

REST to gRPC Migration

gRPC-JSON Gateway

import (
    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
)

func main() {
    // gRPC server
    grpcServer := grpc.NewServer()
    user.RegisterUserServiceServer(grpcServer, &userService{})
    
    // HTTP gateway
    mux := runtime.NewServeMux()
    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
    user.RegisterUserServiceHandlerFromEndpoint(context.Background(), mux, "localhost:50051", opts)
    
    // Combined server
    httpMux := http.NewServeMux()
    httpMux.Handle("/", mux)
    
    http.ListenAndServe(":8080", httpMux)
}

Proto with HTTP Annotations

import "google/api/annotations.proto";

service UserService {
  rpc GetUser (GetUserRequest) returns (User) {
    option (google.api.http) = {
      get: "/api/v1/users/{id}"
    };
  }
  
  rpc CreateUser (CreateUserRequest) returns (User) {
    option (google.api.http) = {
      post: "/api/v1/users"
      body: "*"
    };
  }
  
  rpc UpdateUser (UpdateUserRequest) returns (User) {
    option (google.api.http) = {
      put: "/api/v1/users/{id}"
      body: "user"
    };
  }
  
  rpc DeleteUser (DeleteUserRequest) returns (Empty) {
    option (google.api.http) = {
      delete: "/api/v1/users/{id}"
    };
  }
}

Best Practices

Proto Design

// Good: Clear, versioned
message UserV1 {
  string id = 1;
  string name = 2;
  string email = 3;
}

// Good: Use enums for status
enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_ACTIVE = 1;
  STATUS_INACTIVE = 2;
}

// Good: Use timestamps
import "google/protobuf/timestamp.proto";
message Event {
  google.protobuf.Timestamp created_at = 1;
}

// Good: Add comments
// This message represents a user in the system
message User {
  // Unique identifier
  string id = 1;
}

Performance Tips

// Connection pooling
conn, err := grpc.Dial(
    "server:50051",
    grpc.WithBalancer(roundrobin.NewResolver()),
    grpc.WithTransportCredentials(credentials),
)

// Reuse connections
grpc.DialContext(ctx, "server:50051",
    grpc.WithTransportCredentials(creds),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:    20 * time.Second,
        Timeout: 10 * time.Second,
    }),
)

// Enable compression
grpc.Dial("server:50051",
    grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
)

External Resources


Comments