Go Microservices Contracts with Protobuf and gRPC
Chapter 1 of my 6-month journey moving from frontend to Go: hard lessons, Protobuf contracts, gRPC, and how type-safe APIs reduce production surprises.
Six months ago, I left TypeScript to study Go. And I’ll be honest: when I decided to dive into Golang, I thought it would be smooth sailing. I’d been a frontend developer for seven years, living in TypeScript daily, and I assumed, “It’s just another language; I’ll pick it up fast.”
Wrong.
The first month was brutal. Pointers, memory management, goroutines… it was all new. But when I hit the microservices chapter, that’s when things got serious.
What drove me to this
In my last frontend project, we had a REST API that was a mess. The backend would change a field, and we’d only find out during integration. “Oops, now it’s user_email instead of email.” Nothing broke at build time, but in production, it crashed.
I thought: “There has to be a better way.”
That’s when I found gRPC and Protobuf. The idea of a strongly typed contract sounded like TypeScript to me. If the type is wrong, the compiler warns you before you even run the code.
Starting from absolute zero
My first challenge: I didn’t even know what Protobuf was.
I searched, read some docs, but nothing made sense until I started actually coding. The first thing I learned: Protobuf is a language for defining contracts. Like a TypeScript Interface, but for communication between services.
My first .proto file:
syntax = "proto3";
package userservice;
option go_package = "github.com/meuprojeto/proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetUserRequest {
int64 user_id = 1;
}
message GetUserResponse {
User user = 1;
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
What confused me at first:
- Why the numbers? (
= 1,= 2, etc.) I found it weird. In TypeScript, I just declare the field. But I learned that these numbers are the IDs in binary serialization. If you change them, you break compatibility. Renaming the field is fine, but the number is what matters. - Why the different syntax? Protobuf isn’t Go. It’s a language of its own for defining schemas. Then you run a command, and it generates the Go code.
- How to generate the code? Here was pure confusion. I installed
protoc, installed the Go plugins, configured thePATH… I spent about 3 hours realizing I needed to install three separate things.
What I did (after a lot of pain)
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Then generate
protoc --go_out=. --go-grpc_out=. user.proto
When the code was generated, I opened the files and was impressed. Hundreds of lines. But the cool part is I don’t need to read it all. The important thing is that now I have types.
// Generated file user.pb.go (excerpt)
type User struct {
State protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
The first time I tried to use it, I messed up badly:
// My initial mistake
user := &User{
id: 1, // Error! Field is exported (Id with capital I)
name: "João",
}
In Go, exported fields start with a capital letter. The generated code follows this rule. I had to remember that basic rule I already knew but forgot in the heat of the moment.
Implementing the server
Here came another surprise. gRPC generates an interface I need to implement:
// UserServiceServer is the generated interface
type UserServiceServer interface {
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
}
In TypeScript, I’m used to classes. Here, we implement structs with methods:
type server struct {
pb.UnimplementedUserServiceServer // always include this!
db *UserRepository
}
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
user, err := s.db.FindByID(ctx, req.UserId)
if err != nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
return &pb.GetUserResponse{
User: &pb.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
},
}, nil
}
The UnimplementedUserServiceServer is crucial. If I add a new method to the .proto and forget to implement it, the code compiles but returns an error at runtime. It’s a safety net.
What confused me the most
In frontend, I used fetch and that was it. In Go, almost every function receives a context.Context. At first, I didn’t understand why.
Later I realized: it’s for controlling timeouts, cancellations, and propagating values across goroutines. It makes sense in microservices where calls can hang indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 123})
Without the context, the call can hang forever. I let this happen once in a test and had to manually kill the process.
Errors
In JavaScript, I use try/catch. In Go, errors are returned as values:
resp, err := client.GetUser(ctx, req)
if err != nil {
// handle error
}
And gRPC has specific error codes:
return nil, status.Errorf(codes.NotFound, "user not found")
return nil, status.Errorf(codes.InvalidArgument, "email required")
At first, I just used codes.Internal for everything. I learned that using the right code helps the client know how to react.
Connections
Making a gRPC connection was also strange:
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
In Node.js, I’d use fetch or axios. Here, I have to create a connection, keep it open, and close it later. It seems like more work, but the performance pays off.
Comparing it to what I already know
Here’s a translation of the concepts I already mastered in frontend to the Go world. The idea isn’t just to list them, but to explain the why behind each.
Contract: Interface/Type ⇒ .proto file
In TypeScript, I define contracts like this:
interface User {
id: number;
name: string;
email: string;
}
This helps the compiler validate types in my project, but it doesn’t prevent anything in service-to-service communication. The backend can change a field, and I only find out during integration.
In Go with gRPC, the contract is a separate .proto file:
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
The crucial difference: this file is the source of truth. It’s shared across all services. If someone changes something, the code breaks at compile time. There’s no “it’ll work in production.”
Call: fetch/axios ⇒ gRPC client
In frontend, I do:
const user = await fetch('/api/user/123').then(r => r.json());
It works, but it’s fragile. What comes back? I don’t know until I run it.
In Go, I have a generated client:
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 123})
The client.GetUser already knows exactly what parameters to expect and what it will receive. If I pass the wrong argument, it won’t compile.
Types: Compile-time in both
Here the similarity is strong. Both TypeScript and Go check types before running.
The difference: Go is stricter. In TypeScript, I can use any and work around it. In Go, there’s no escaping. Either the type matches, or it doesn’t compile.
At first, this annoyed me. Later, I realized it’s a protection.
Errors: try/catch ⇒ return error
JavaScript:
try {
const user = await api.getUser();
} catch (err) {
console.error(err);
}
Go:
resp, err := client.GetUser(ctx, req)
if err != nil {
log.Printf("erro: %v", err)
return
}
In JavaScript, errors can be accidentally ignored. In Go, the error is a return value. You have to handle it. There’s no way to forget.
At first, it seems annoying. Later, you realize it avoids subtle bugs.
Async: Promise/async-await ⇒ Goroutines + channels
In frontend, async is all based on Promises:
async function getData() {
const user = await fetchUser();
const posts = await fetchPosts(user.id);
return { user, posts };
}
In Go, concurrency is different. You use goroutines:
go func() {
user, _ := fetchUser()
ch <- user
}()
go func() {
posts, _ := fetchPosts(id)
ch <- posts
}()
user := <-ch
posts := <-ch
Here, the learning curve is steeper. Goroutines are lightweight, but you have to think about synchronization, channels, and contexts. I’m still learning this.
What I liked the most
The part I liked most: strong typing. In TypeScript, I already liked this, and in Go, it’s even stricter. If the type doesn’t match, it doesn’t compile.
But what really changed my way of thinking was the idea of an executable contract. It’s not documentation that no one reads. It’s code the compiler verifies. This gave me a confidence I didn’t have with REST APIs.
This version explains the “why” behind each comparison better, not just the “what.” Is it better?
Where I got stuck
Dependencies
Managing dependencies in Go is different. There’s go mod, which is like npm, but it works differently. I tried importing packages and got errors because I hadn’t initialized the module.
go mod init github.com/meuprojeto
go get google.golang.org/grpc
Build
In Node.js, I run node index.js. In Go, I have to compile first:
go build ./cmd/server
./server
Or use go run ./cmd/server for development.
Debug
Debugging Go is different. There’s no console.log. I used fmt.Println at first, then learned to use the Delve debugger.
Conclusion
Studying Go as a frontend developer was challenging. So much new stuff, so much different syntax, so many concepts I never had to think about before.
But gRPC and Protobuf made sense quickly. The idea of a strongly typed contract is something I already valued with TypeScript, and here it’s even stronger.
If you’re also coming from frontend and studying Go, my advice is: don’t be scared by the amount of new things. Go slow, understand each concept before jumping to the next. And most importantly: don’t skip the contracts part. That’s what will save you headaches later.
I still have a lot to learn, but I can already see the path. And if I could do it, I believe anyone can.
References and Documentation
Here are the essential links I used during my studies. Save this list, because it will save your life when you get stuck.
Go
- Official Go Documentation - Start here
- A Tour of Go - Interactive tutorial for beginners
- Go by Example - Short, direct examples
- Effective Go - Style guide and best practices
gRPC
- Official gRPC Documentation
- Go Quickstart - Step-by-step tutorial
- gRPC Basics - Fundamental concepts
Protobuf
- Protocol Buffers Docs- Syntax and concepts
Tools
- grpcurl - The “curl” of gRPC for testing endpoints
- buf- Linter and proto manager (essential for avoiding breaks)
Community
- r/golang - Official Reddit
- Gophers Slack - Community on Slack
Quick Tip: Don’t try to consume everything at once. Pick a tutorial, follow it to the end, and only then move to the next. If you get stuck on something, search on Reddit or ask in Slack. The communities are very active.