Skip to content
Codeloom
Go

Building gRPC Services in Go: Complete Guide

Build production-ready gRPC services in Go with protobuf definitions, server and client implementation, streaming, interceptors, and error handling.

·9 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • Define services with Protocol Buffers
  • Implement gRPC servers and clients in Go
  • Use unary and streaming RPCs
  • Add interceptors for logging and auth

Prerequisites

  • Go fundamentals
  • Basic understanding of RPC concepts
  • Familiarity with Protocol Buffers syntax

gRPC is a high-performance RPC framework that uses Protocol Buffers for serialization and HTTP/2 for transport. It gives you strongly typed contracts between services, bidirectional streaming, and efficient binary encoding. Go has excellent gRPC support through the official google.golang.org/grpc package.

Project Setup

First, install the required tools.

# Install protoc compiler
# macOS
brew install protobuf

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

Set up the project structure.

myapp/
├── proto/
│   └── todo/
│       └── todo.proto
├── server/
│   └── main.go
├── client/
│   └── main.go
├── internal/
│   └── service/
│       └── todo.go
├── go.mod
└── go.sum

Defining the Service with Protobuf

Write the service definition in Protocol Buffers.

// proto/todo/todo.proto
syntax = "proto3";

package todo;

option go_package = "myapp/gen/todo";

import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";

service TodoService {
  // Unary RPCs
  rpc CreateTodo(CreateTodoRequest) returns (Todo);
  rpc GetTodo(GetTodoRequest) returns (Todo);
  rpc UpdateTodo(UpdateTodoRequest) returns (Todo);
  rpc DeleteTodo(DeleteTodoRequest) returns (google.protobuf.Empty);
  rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);

  // Server streaming: watch for changes
  rpc WatchTodos(WatchTodosRequest) returns (stream TodoEvent);
}

message Todo {
  string id = 1;
  string title = 2;
  string description = 3;
  bool completed = 4;
  google.protobuf.Timestamp created_at = 5;
  google.protobuf.Timestamp updated_at = 6;
}

message CreateTodoRequest {
  string title = 1;
  string description = 2;
}

message GetTodoRequest {
  string id = 1;
}

message UpdateTodoRequest {
  string id = 1;
  string title = 2;
  string description = 3;
  bool completed = 4;
}

message DeleteTodoRequest {
  string id = 1;
}

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

message ListTodosResponse {
  repeated Todo todos = 1;
  string next_page_token = 2;
}

message WatchTodosRequest {}

message TodoEvent {
  enum EventType {
    CREATED = 0;
    UPDATED = 1;
    DELETED = 2;
  }
  EventType type = 1;
  Todo todo = 2;
}

Generate Go code from the proto file.

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

Implementing the Server

Create the service implementation.

// internal/service/todo.go
package service

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/google/uuid"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/emptypb"
	"google.golang.org/protobuf/types/known/timestamppb"

	pb "myapp/gen/todo"
)

type TodoServer struct {
	pb.UnimplementedTodoServiceServer

	mu        sync.RWMutex
	todos     map[string]*pb.Todo
	watchers  []chan *pb.TodoEvent
	watcherMu sync.Mutex
}

func NewTodoServer() *TodoServer {
	return &TodoServer{
		todos: make(map[string]*pb.Todo),
	}
}

func (s *TodoServer) CreateTodo(ctx context.Context, req *pb.CreateTodoRequest) (*pb.Todo, error) {
	if req.Title == "" {
		return nil, status.Error(codes.InvalidArgument, "title is required")
	}

	now := timestamppb.Now()
	todo := &pb.Todo{
		Id:          uuid.New().String(),
		Title:       req.Title,
		Description: req.Description,
		Completed:   false,
		CreatedAt:   now,
		UpdatedAt:   now,
	}

	s.mu.Lock()
	s.todos[todo.Id] = todo
	s.mu.Unlock()

	s.notifyWatchers(&pb.TodoEvent{
		Type: pb.TodoEvent_CREATED,
		Todo: todo,
	})

	return todo, nil
}

func (s *TodoServer) GetTodo(ctx context.Context, req *pb.GetTodoRequest) (*pb.Todo, error) {
	if req.Id == "" {
		return nil, status.Error(codes.InvalidArgument, "id is required")
	}

	s.mu.RLock()
	todo, ok := s.todos[req.Id]
	s.mu.RUnlock()

	if !ok {
		return nil, status.Error(codes.NotFound, fmt.Sprintf("todo %s not found", req.Id))
	}

	return todo, nil
}

func (s *TodoServer) UpdateTodo(ctx context.Context, req *pb.UpdateTodoRequest) (*pb.Todo, error) {
	if req.Id == "" {
		return nil, status.Error(codes.InvalidArgument, "id is required")
	}

	s.mu.Lock()
	todo, ok := s.todos[req.Id]
	if !ok {
		s.mu.Unlock()
		return nil, status.Error(codes.NotFound, fmt.Sprintf("todo %s not found", req.Id))
	}

	if req.Title != "" {
		todo.Title = req.Title
	}
	if req.Description != "" {
		todo.Description = req.Description
	}
	todo.Completed = req.Completed
	todo.UpdatedAt = timestamppb.Now()
	s.mu.Unlock()

	s.notifyWatchers(&pb.TodoEvent{
		Type: pb.TodoEvent_UPDATED,
		Todo: todo,
	})

	return todo, nil
}

func (s *TodoServer) DeleteTodo(ctx context.Context, req *pb.DeleteTodoRequest) (*emptypb.Empty, error) {
	if req.Id == "" {
		return nil, status.Error(codes.InvalidArgument, "id is required")
	}

	s.mu.Lock()
	todo, ok := s.todos[req.Id]
	if !ok {
		s.mu.Unlock()
		return nil, status.Error(codes.NotFound, fmt.Sprintf("todo %s not found", req.Id))
	}
	delete(s.todos, req.Id)
	s.mu.Unlock()

	s.notifyWatchers(&pb.TodoEvent{
		Type: pb.TodoEvent_DELETED,
		Todo: todo,
	})

	return &emptypb.Empty{}, nil
}

func (s *TodoServer) ListTodos(ctx context.Context, req *pb.ListTodosRequest) (*pb.ListTodosResponse, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	todos := make([]*pb.Todo, 0, len(s.todos))
	for _, t := range s.todos {
		todos = append(todos, t)
	}

	return &pb.ListTodosResponse{
		Todos: todos,
	}, nil
}

func (s *TodoServer) WatchTodos(req *pb.WatchTodosRequest, stream pb.TodoService_WatchTodosServer) error {
	ch := make(chan *pb.TodoEvent, 10)

	s.watcherMu.Lock()
	s.watchers = append(s.watchers, ch)
	s.watcherMu.Unlock()

	defer func() {
		s.watcherMu.Lock()
		for i, w := range s.watchers {
			if w == ch {
				s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
				break
			}
		}
		s.watcherMu.Unlock()
		close(ch)
	}()

	for {
		select {
		case event := <-ch:
			if err := stream.Send(event); err != nil {
				return err
			}
		case <-stream.Context().Done():
			return nil
		}
	}
}

func (s *TodoServer) notifyWatchers(event *pb.TodoEvent) {
	s.watcherMu.Lock()
	defer s.watcherMu.Unlock()

	for _, ch := range s.watchers {
		select {
		case ch <- event:
		default:
			// Drop event if watcher is too slow
		}
	}
}

Starting the gRPC Server

// server/main.go
package main

import (
	"log"
	"net"

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

	pb "myapp/gen/todo"
	"myapp/internal/service"
)

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

	grpcServer := grpc.NewServer()
	pb.RegisterTodoServiceServer(grpcServer, service.NewTodoServer())

	// Enable reflection for tools like grpcurl
	reflection.Register(grpcServer)

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

Building the Client

// client/main.go
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	pb "myapp/gen/todo"
)

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

	client := pb.NewTodoServiceClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Create a todo
	todo, err := client.CreateTodo(ctx, &pb.CreateTodoRequest{
		Title:       "Learn gRPC",
		Description: "Build a todo service with gRPC and Go",
	})
	if err != nil {
		log.Fatalf("CreateTodo failed: %v", err)
	}
	fmt.Printf("Created: %s - %s\n", todo.Id, todo.Title)

	// Get the todo
	fetched, err := client.GetTodo(ctx, &pb.GetTodoRequest{Id: todo.Id})
	if err != nil {
		log.Fatalf("GetTodo failed: %v", err)
	}
	fmt.Printf("Fetched: %s - %s\n", fetched.Id, fetched.Title)

	// List all todos
	list, err := client.ListTodos(ctx, &pb.ListTodosRequest{PageSize: 10})
	if err != nil {
		log.Fatalf("ListTodos failed: %v", err)
	}
	fmt.Printf("Total todos: %d\n", len(list.Todos))
}

Interceptors for Cross-Cutting Concerns

Interceptors are gRPC’s equivalent of HTTP middleware. They let you add logging, authentication, metrics, and other cross-cutting concerns.

package interceptor

import (
	"context"
	"log/slog"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
)

// LoggingUnary logs every unary RPC call.
func LoggingUnary(logger *slog.Logger) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req any,
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (any, error) {
		start := time.Now()
		resp, err := handler(ctx, req)
		duration := time.Since(start)

		code := codes.OK
		if err != nil {
			code = status.Code(err)
		}

		logger.Info("gRPC call",
			"method", info.FullMethod,
			"code", code.String(),
			"duration", duration,
		)

		return resp, err
	}
}

// AuthUnary validates bearer tokens on incoming requests.
func AuthUnary(validToken string) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req any,
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (any, error) {
		md, ok := metadata.FromIncomingContext(ctx)
		if !ok {
			return nil, status.Error(codes.Unauthenticated, "missing metadata")
		}

		tokens := md.Get("authorization")
		if len(tokens) == 0 || tokens[0] != "Bearer "+validToken {
			return nil, status.Error(codes.Unauthenticated, "invalid token")
		}

		return handler(ctx, req)
	}
}

// RecoveryUnary catches panics and returns internal errors.
func RecoveryUnary(logger *slog.Logger) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req any,
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (resp any, err error) {
		defer func() {
			if r := recover(); r != nil {
				logger.Error("panic recovered",
					"method", info.FullMethod,
					"panic", r,
				)
				err = status.Error(codes.Internal, "internal server error")
			}
		}()
		return handler(ctx, req)
	}
}

Register interceptors when creating the server using grpc.ChainUnaryInterceptor.

grpcServer := grpc.NewServer(
	grpc.ChainUnaryInterceptor(
		interceptor.RecoveryUnary(logger),
		interceptor.LoggingUnary(logger),
		interceptor.AuthUnary(os.Getenv("GRPC_AUTH_TOKEN")),
	),
)

Proper Error Handling

gRPC uses status codes similar to HTTP. Always return structured errors with appropriate codes.

import (
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// Use appropriate status codes
func (s *TodoServer) GetTodo(ctx context.Context, req *pb.GetTodoRequest) (*pb.Todo, error) {
	if req.Id == "" {
		return nil, status.Error(codes.InvalidArgument, "id must not be empty")
	}

	todo, err := s.store.Get(ctx, req.Id)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return nil, status.Error(codes.NotFound, "todo not found")
		}
		return nil, status.Error(codes.Internal, "failed to fetch todo")
	}

	return todo, nil
}

On the client side, check status codes to handle errors appropriately.

todo, err := client.GetTodo(ctx, &pb.GetTodoRequest{Id: "abc"})
if err != nil {
	st, ok := status.FromError(err)
	if ok {
		switch st.Code() {
		case codes.NotFound:
			fmt.Println("Todo not found")
		case codes.InvalidArgument:
			fmt.Printf("Bad request: %s\n", st.Message())
		default:
			fmt.Printf("RPC error: %s\n", st.Message())
		}
	}
	return
}

Testing gRPC Services

Test your gRPC service using bufconn, which creates an in-memory connection.

package service_test

import (
	"context"
	"net"
	"testing"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/test/bufconn"

	pb "myapp/gen/todo"
	"myapp/internal/service"
)

func setupTest(t *testing.T) pb.TodoServiceClient {
	t.Helper()

	lis := bufconn.Listen(1024 * 1024)
	srv := grpc.NewServer()
	pb.RegisterTodoServiceServer(srv, service.NewTodoServer())

	go func() {
		if err := srv.Serve(lis); err != nil {
			t.Logf("server exited: %v", err)
		}
	}()
	t.Cleanup(srv.Stop)

	conn, err := grpc.NewClient("passthrough:///bufconn",
		grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
			return lis.DialContext(ctx)
		}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	if err != nil {
		t.Fatalf("failed to dial: %v", err)
	}
	t.Cleanup(func() { conn.Close() })

	return pb.NewTodoServiceClient(conn)
}

func TestCreateAndGetTodo(t *testing.T) {
	client := setupTest(t)
	ctx := context.Background()

	created, err := client.CreateTodo(ctx, &pb.CreateTodoRequest{
		Title:       "Test Todo",
		Description: "A test todo item",
	})
	if err != nil {
		t.Fatalf("CreateTodo: %v", err)
	}

	got, err := client.GetTodo(ctx, &pb.GetTodoRequest{Id: created.Id})
	if err != nil {
		t.Fatalf("GetTodo: %v", err)
	}

	if got.Title != "Test Todo" {
		t.Errorf("title = %q, want %q", got.Title, "Test Todo")
	}
}

Wrapping Up

gRPC in Go gives you strongly typed APIs, efficient binary serialization, and built-in streaming. Define your service contract in protobuf, generate the Go code, implement the server interface, and use interceptors for cross-cutting concerns. The status package provides structured error handling with standard codes. For testing, use bufconn to avoid network overhead. This foundation scales from simple internal services to large microservice architectures.