gRPC in Go: Implementation and Sharing Proto Across Microservices

gRPC in Go: Implementation and Sharing Proto Across Microservices

How to implement gRPC in a Go project: creating proto files, generating code, setting up the server and client. Ways to share proto across microservices: git submodules, a separate repository, buf registry. Practical examples from real experience.

Working with a microservice architecture in Go or Golang? Sooner or later you get tired of shuttling JSON back and forth. REST APIs are great for external clients, but for internal communication between services there’s a better option — gRPC.

I recently had to implement gRPC in a Go project, and ran into the question of how to properly organize work with proto files (Protocol Buffers) when you have several repositories. I’ll share what I tried and what worked. If you’re curious about how to avoid writing garbage Go code, check out my article on Go anti-patterns — it has real examples from production.

Why bother with this at all?

Honestly, at first I thought “why the hell do I need this, REST works fine as is.” But as the number of microservices grew, performance problems started showing up:

  • JSON parsing is slow, especially at scale
  • No typing — you can accidentally send a string instead of an int, and nobody notices until runtime
  • HTTP/1.1 — a ton of overhead on every request
  • No streaming out of the box

gRPC for Go solves these problems:

  • Typed contracts — Protocol Buffers strictly define the data structure, no surprises
  • HTTP/2 — multiplexing, less overhead
  • Binary serialization — faster and more compact than JSON
  • Streaming — built-in support for streaming data
  • Code generation — automatic generation of Go client and server code
  • Performance — 5-10x faster than REST for internal communication between microservices

If you have a microservice architecture and services talk to each other, gRPC will give you a noticeable performance boost. Tested in practice. By the way, if you’re interested in architecture and how to avoid circular dependencies, check out Go anti-patterns — it has real examples of production issues.

What you need to install for gRPC in Go

To work with gRPC in Go or Golang, you need to install a few tools:

# Install protoc (Protocol Buffers compiler)
# For macOS
brew install protobuf

# For Linux
apt install -y protobuf-compiler

# 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

# Add to PATH if you haven't already
export PATH="$PATH:$(go env GOPATH)/bin"

Let’s check that everything is in place:

protoc --version
# libprotoc 3.21.12 (or higher)

If you see a version number, you’re good to go.

Creating your first proto file (Protocol Buffers)

For this example, let’s build a simple gRPC service in Go for managing users. The project structure looks like this:

myproject/
├── api/
│   └── proto/
│       └── user/
│           └── v1/
│               └── user.proto
├── cmd/
│   ├── server/
│   │   └── main.go
│   └── client/
│       └── main.go
├── internal/
│   └── service/
│       └── user.go
├── go.mod
└── Makefile

Create api/proto/user/v1/user.proto:

syntax = "proto3";

package user.v1;

option go_package = "github.com/yourusername/myproject/gen/go/user/v1;userv1";

import "google/protobuf/timestamp.proto";

// User service for managing users
service UserService {
  // Create a new user
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  
  // Get a user by ID
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  
  // Get a list of users (with stream support)
  rpc ListUsers(ListUsersRequest) returns (stream User);
  
  // Update a user
  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
  
  // Delete a user
  rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
}

// User model
message User {
  string id = 1;
  string email = 2;
  string name = 3;
  google.protobuf.Timestamp created_at = 4;
  google.protobuf.Timestamp updated_at = 5;
}

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

message CreateUserResponse {
  User user = 1;
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}

message UpdateUserRequest {
  string id = 1;
  string email = 2;
  string name = 3;
}

message UpdateUserResponse {
  User user = 1;
}

message DeleteUserRequest {
  string id = 1;
}

message DeleteUserResponse {
  bool success = 1;
}

Generating Go code from proto files

So you don’t have to type a long command every time to generate Go code from Protocol Buffers, let’s make a Makefile:

.PHONY: proto
proto:
	@echo "Generating proto files..."
	@mkdir -p gen/go
	@protoc \
		--proto_path=api/proto \
		--go_out=gen/go \
		--go_opt=paths=source_relative \
		--go-grpc_out=gen/go \
		--go-grpc_opt=paths=source_relative \
		api/proto/user/v1/*.proto
	@echo "Proto generation completed!"

.PHONY: clean
clean:
	@rm -rf gen/

Generate the code:

make proto

You’ll get two files:

  • gen/go/user/v1/user.pb.go — data structures
  • gen/go/user/v1/user_grpc.pb.go — server and client interfaces

Building a gRPC server in Go

Create internal/service/user.go for our gRPC service:

package service

import (
	"context"
	"fmt"
	"time"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/timestamppb"

	userv1 "github.com/yourusername/myproject/gen/go/user/v1"
)

type UserService struct {
	userv1.UnimplementedUserServiceServer
	users map[string]*userv1.User
}

func NewUserService() *UserService {
	return &UserService{
		users: make(map[string]*userv1.User),
	}
}

func (s *UserService) CreateUser(ctx context.Context, req *userv1.CreateUserRequest) (*userv1.CreateUserResponse, error) {
	// Validation
	if req.Email == "" {
		return nil, status.Error(codes.InvalidArgument, "email is required")
	}

	// Generate ID (use UUID in production)
	id := fmt.Sprintf("user_%d", time.Now().UnixNano())
	now := timestamppb.Now()

	user := &userv1.User{
		Id:        id,
		Email:     req.Email,
		Name:      req.Name,
		CreatedAt: now,
		UpdatedAt: now,
	}

	s.users[id] = user

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

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

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

func (s *UserService) ListUsers(req *userv1.ListUsersRequest, stream userv1.UserService_ListUsersServer) error {
	// Streaming response - send users one at a time
	for _, user := range s.users {
		if err := stream.Send(user); err != nil {
			return err
		}
	}
	return nil
}

func (s *UserService) UpdateUser(ctx context.Context, req *userv1.UpdateUserRequest) (*userv1.UpdateUserResponse, error) {
	user, ok := s.users[req.Id]
	if !ok {
		return nil, status.Error(codes.NotFound, "user not found")
	}

	// Update only the fields that were passed
	if req.Email != "" {
		user.Email = req.Email
	}
	if req.Name != "" {
		user.Name = req.Name
	}
	user.UpdatedAt = timestamppb.Now()

	return &userv1.UpdateUserResponse{
		User: user,
	}, nil
}

func (s *UserService) DeleteUser(ctx context.Context, req *userv1.DeleteUserRequest) (*userv1.DeleteUserResponse, error) {
	_, ok := s.users[req.Id]
	if !ok {
		return nil, status.Error(codes.NotFound, "user not found")
	}

	delete(s.users, req.Id)

	return &userv1.DeleteUserResponse{
		Success: true,
	}, nil
}

Create cmd/server/main.go:

package main

import (
	"fmt"
	"log"
	"net"

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

	userv1 "github.com/yourusername/myproject/gen/go/user/v1"
	"github.com/yourusername/myproject/internal/service"
)

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

	// Create the gRPC server
	grpcServer := grpc.NewServer()

	// Register our service
	userService := service.NewUserService()
	userv1.RegisterUserServiceServer(grpcServer, userService)

	// Enable reflection for grpcurl and Postman
	reflection.Register(grpcServer)

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

Run the server:

go run cmd/server/main.go

Building a gRPC client in Go

Create cmd/client/main.go to connect to our gRPC server:

package main

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

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

	userv1 "github.com/yourusername/myproject/gen/go/user/v1"
)

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

	// Create the client
	client := userv1.NewUserServiceClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Create a user
	createResp, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
		Email: "test@example.com",
		Name:  "Test User",
	})
	if err != nil {
		log.Fatalf("CreateUser failed: %v", err)
	}
	fmt.Printf("Created user: %v\n", createResp.User)

	// Get a user
	getResp, err := client.GetUser(ctx, &userv1.GetUserRequest{
		Id: createResp.User.Id,
	})
	if err != nil {
		log.Fatalf("GetUser failed: %v", err)
	}
	fmt.Printf("Got user: %v\n", getResp.User)

	// Create a few more users
	for i := 0; i < 3; i++ {
		_, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
			Email: fmt.Sprintf("user%d@example.com", i),
			Name:  fmt.Sprintf("User %d", i),
		})
		if err != nil {
			log.Fatalf("CreateUser failed: %v", err)
		}
	}

	// Get the list of users via stream
	stream, err := client.ListUsers(ctx, &userv1.ListUsersRequest{})
	if err != nil {
		log.Fatalf("ListUsers failed: %v", err)
	}

	fmt.Println("\nAll users:")
	for {
		user, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("ListUsers stream error: %v", err)
		}
		fmt.Printf("  - %s: %s (%s)\n", user.Id, user.Name, user.Email)
	}
}

Run the client:

go run cmd/client/main.go

How to share proto across repositories in a microservice architecture

Now here’s where it gets interesting. When you have a microservice architecture with several Go services in different repositories, the question comes up: how do you organize work with proto files (Protocol Buffers)? I tried several approaches for Go projects, and I’ll tell you what worked and what didn’t.

Option 1: Git Submodules

The simplest approach is to make a separate repository with proto files and hook it up as a submodule. This is what I did in the beginning, when the team was small.

# Create the proto repository
mkdir myproject-proto
cd myproject-proto
git init

# Structure
# myproject-proto/
# ├── user/v1/user.proto
# ├── order/v1/order.proto
# └── payment/v1/payment.proto

# Add it as a submodule in each service
cd ../user-service
git submodule add https://github.com/company/myproject-proto.git api/proto
git submodule update --init --recursive

Pros:

  • Easy to set up, literally a couple of minutes
  • Works out of the box with Git
  • Versioning is there

Cons:

  • You have to update submodules manually (git submodule update --remote)
  • It’s easy to forget to commit the submodule update (I did this constantly)
  • Conflicts when several people work on it at the same time

When to use it: Small team (2-5 people), simple projects. Not very convenient for larger teams.

Option 2: A separate repository with a Go module

I tried this approach once the team grew and the number of microservices increased. The idea is simple: make a repository with proto files (Protocol Buffers), generate the Go code in it, and publish it as a regular Go module.

Structure of the myproject-proto repository:

myproject-proto/
├── proto/
│   ├── user/v1/user.proto
│   ├── order/v1/order.proto
│   └── payment/v1/payment.proto
├── gen/go/
│   ├── user/v1/
│   ├── order/v1/
│   └── payment/v1/
├── go.mod
├── Makefile
└── buf.gen.yaml

go.mod:

module github.com/company/myproject-proto

go 1.21

require (
	google.golang.org/grpc v1.60.0
	google.golang.org/protobuf v1.32.0
)

Makefile:

.PHONY: generate
generate:
	@protoc \
		--proto_path=proto \
		--go_out=gen/go \
		--go_opt=paths=source_relative \
		--go-grpc_out=gen/go \
		--go-grpc_opt=paths=source_relative \
		proto/**/**/*.proto
	@git add gen/
	@echo "Generated Go code committed"

In other services, you just import it:

import (
    userv1 "github.com/company/myproject-proto/gen/go/user/v1"
)
# In the services
go get github.com/company/myproject-proto@v1.2.3

Pros:

  • Integration is simple — a regular go get, just like any library
  • Versioning via Git tags works out of the box
  • Generated code lives in the repository (you can inspect what came out)

Cons:

  • Generated code in Git (the repository grows, but it’s not a big deal)
  • You have to cut releases manually (can be automated via CI/CD)
  • Whenever proto changes, you need to commit the generated code too (a bit inconvenient)

When to use it: Medium team (5-15 people), when you need simplicity and native integration with Go. I used this approach until I switched to Buf.

Option 3: Buf Schema Registry

This is the approach I currently use for larger Go projects with a microservice architecture. Buf is a modern tool for working with Protocol Buffers. Essentially it’s a registry for proto files, like npm for JS packages, but for Protocol Buffers.

Installation:

brew install bufbuild/buf/buf
# or
go install github.com/bufbuild/buf/cmd/buf@latest

Create buf.yaml in the proto repository:

version: v1
name: buf.build/company/myproject
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

Create buf.gen.yaml for code generation:

version: v1
managed:
  enabled: true
  go_package_prefix:
    default: github.com/company/myproject-proto/gen/go
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative

Generate the code:

buf generate

Publish to the Buf Schema Registry:

# Log in
buf registry login

# Push proto to the registry
buf push

In other services, create a buf.gen.yaml:

version: v1
managed:
  enabled: true
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative

And generate from the registry:

buf generate buf.build/company/myproject

Pros:

  • Centralized proto storage (everything in one place)
  • Breaking changes detection — automatically checks compatibility (very handy)
  • Versioning and dependency management work out of the box
  • No need to commit generated code (cleaner repository)
  • Linting and validation out of the box (fewer mistakes)
  • Private registries for enterprise (you can self-host your own)

Cons:

  • An extra tool in the stack (you have to learn it)
  • Dependency on an external service (though you can self-host your own Buf Registry)
  • Takes time to learn (but it’s worth it)

When to use it: Large team (15+ people), lots of microservices in Go projects, when compatibility and automation matter. I switched to Buf for working with Protocol Buffers once the team grew, and I don’t regret it. By the way, for automating routine tasks in Go, Cursor AI really helps — it’s great at code generation and refactoring.

Option 4: Auto-generation in CI/CD

If you already have CI/CD set up, you can automate code generation there. I haven’t tried this myself, but I’ve seen it done at larger companies.

.github/workflows/generate.yml:

name: Generate Proto

on:
  push:
    branches: [main]
    paths:
      - 'proto/**'

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      
      - name: Install protoc
        run: |
          sudo apt-get install -y 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
      
      - name: Generate code
        run: make generate
      
      - name: Commit and push
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          git add gen/
          git commit -m "Generate proto code" || exit 0
          git push
      
      - name: Create release
        run: |
          VERSION="v1.0.${{ github.run_number }}"
          git tag $VERSION
          git push origin $VERSION

Pros:

  • Fully automated (nothing to do manually)
  • No human error factor (you won’t forget to update it)
  • Automatic versioning

Cons:

  • More complex setup (you need to figure out CI/CD)
  • Requires CI/CD (if you don’t have it, this isn’t an option)

When to use it: Enterprise projects with mature CI/CD. Overkill for small teams.

What should you choose for a Go project?

Here’s what I’ve learned working with gRPC in Go and a microservice architecture:

OptionSetup complexityConvenienceAutomationTeam size
Git SubmodulesLowMediumNo2-5 people
Separate repositoryLowGoodPartial5-15 people
Buf RegistryMediumExcellentYes15+ people
CI/CD generationHighExcellentYesEnterprise

My advice for Go developers: start with submodules or a separate repository for your proto files, and once the team grows and there are more microservices — move to Buf. There’s no need to build a complex infrastructure right away if the team is small.

What else you need for production in a Go project

For a production gRPC server in Go, it’s worth adding a few important things:

Logging middleware

package middleware

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
)

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
}

Graceful shutdown

package main

import (
	"context"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	"google.golang.org/grpc"
)

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

	grpcServer := grpc.NewServer(
		grpc.UnaryInterceptor(middleware.LoggingInterceptor),
	)

	// ... register services ...

	// Graceful shutdown
	go func() {
		if err := grpcServer.Serve(lis); err != nil {
			log.Fatalf("failed to serve: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	log.Println("Shutting down gRPC server...")
	grpcServer.GracefulStop()
	log.Println("Server stopped")
}

Useful tools for working with gRPC in Go

When you’re working with gRPC in Go projects, these tools really help:

  • grpcurl — curl for gRPC, lets you test the API from the terminal
  • grpcui — a web UI for gRPC (like Swagger, but for gRPC)
  • Evans — an interactive gRPC client
  • Buf — linting, breaking changes detection, schema registry
  • Postman — built-in gRPC support (convenient for testing)

Installing grpcurl:

brew install grpcurl

# Usage example
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"email":"test@example.com","name":"Test"}' \
  localhost:50051 user.v1.UserService/CreateUser

I use grpcurl for quick testing, it’s very convenient. By the way, if you need help automating routine tasks in Go, try Cursor AI — it’s great at refactoring and code generation.

Wrapping up

gRPC for Go (Golang) is a genuinely useful tool for microservice architectures. It gives you typing via Protocol Buffers, performance, and development convenience. Tested in practice on real Go projects.

As for sharing proto files across microservices, here’s my advice:

  • Small team (2-5 people) → Git Submodules (simplest option for Go projects)
  • Medium team (5-15 people) → A separate Go module (convenient, native integration)
  • Large team (15+ people) → Buf Registry (the best option for Protocol Buffers)
  • Enterprise → CI/CD with auto-generation (if the infrastructure is there)

Start simple (submodules or a separate repository), and once your Go project grows and you have more microservices — move to Buf. There’s no need to build a complex infrastructure right away.

If you’re curious about how to avoid writing garbage Go code, check out my article on Go anti-patterns — it has real examples from production. And for automating routine tasks in Go, I highly recommend Cursor AI — it’s great at code generation and refactoring.


P.S. Don’t forget to version your proto files (Protocol Buffers) in Go projects (v1, v2) so you don’t break compatibility when making changes. And yes, use TLS for gRPC connections between microservices in production.