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.
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.
Related articles
- Go Go gRPC Tutorial
Build a gRPC service in Go from scratch: define protobufs, generate code, implement the server, write a client, and understand how streaming and interceptors fit together.
- Node.js Node.js gRPC Server Tutorial
Build a typed, high-performance gRPC server in Node.js with protobuf definitions, streaming RPCs, and production-ready patterns.
- REST APIs REST vs GraphQL vs gRPC: API Styles Compared
Compare REST, GraphQL, and gRPC for API design. Understand tradeoffs in performance, flexibility, and developer experience to pick the right API style.
- Testing Contract Tests Explained: Catching Integration Bugs Early
Understand consumer-driven contract testing, how it differs from integration tests, and how tools like Pact prevent breaking API changes between services.