Don’t Treat .pb.go Files Like Your Own Child: A Go Protobuf Pitfall Guide
Using protobuf‑generated .pb.go structs directly in Go business code introduces zero‑value ambiguities, naming mismatches, and loss of encapsulation, leading to bugs and technical debt; the article explains these pitfalls with concrete examples and shows how to keep protobuf at the transport layer while mapping to clean domain models.
What Protobuf Actually Is
Protobuf is a binary serialization protocol, a cross‑language data‑exchange contract, and a kind of "diplomatic language" for network boundaries. It is not a business‑domain object model, an in‑memory data‑structure specification, or a Go design blueprint.
🧨 The Illusion of Elegance: Using .pb.go Structs Directly in Business Code
Many developers import the generated package, e.g. import "github.com/you/repo/api/v1", and instantiate user := &v1.User{} directly in the domain layer. This practice looks convenient but hides serious problems.
1️⃣ Zero‑Value Disaster
Protobuf v3 removes optional, so every field defaults to its zero value. For example: int32 age = 1; → when omitted,
Age == 0 bool is_vip = 2;→ when omitted, IsVip == false Consequently, the following code cannot distinguish between "age not provided" and "age is actually zero":
func UpdateUser(u *v1.User) {
if u.Age == 0 { // Is this a baby or just missing the age field?
// …logic breaks here
}
}Real incident: a social‑app treated age=0 as a newborn and pushed a "baby care" notification to 800 k silent users.
Solution: use wrapper types such as google.protobuf.Int32Value (or optional in v3.15+). This changes the generated struct to a nested pointer ( *wrapperspb.Int32Value), which re‑introduces pointer indirection and can feel like "Russian nesting dolls".
2️⃣ Naming Hijack
The .proto file often uses snake_case (e.g. user_id), which the Go generator turns into UserId (PascalCase). The resulting code looks like:
u := &v1.User{UserId: 123, UserName: "gopher", IsVip: true}This mixes Go naming conventions with protobuf‑generated names, making the code feel like "Go‑style Python‑style Java".
3️⃣ Encapsulation Death
Protobuf structs are pure data containers; they cannot have methods, custom marshal/unmarshal logic, or business‑level validation. As a result, validation logic is scattered across handlers:
func CreateUser(req *v1.CreateUserRequest) error {
if strings.TrimSpace(req.UserName) == "" {
return errors.New("name empty")
}
if len(req.UserName) > 20 {
return errors.New("name too long")
}
if !regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString(req.UserName) {
return errors.New("invalid chars")
}
// …10 lines of checks spread over many handlers
return nil
}This leads to technical debt: each new field forces updates in multiple handlers, and a missed check can cause security issues, e.g. username: " DROP TABLE users;--" slipping into production.
✅ Correct Approach: Keep Protobuf at the Transport Border
🌍 The network is a border; Protobuf is the diplomatic document. Inside the border, Go structs are the citizens' IDs.
Define a clear boundary where protobuf is used only for API request/response serialization. Convert the protobuf message to a pure Go domain struct before any business logic.
// api/v1/user.proto – only defines the contract
message CreateUserRequest {
string user_name = 1;
int32 age = 2;
}
// Generated file api/v1/user.pb.go is imported only in the transport layer.
// domain/user.go – the real business object
type User struct {
ID uuid.UUID
Name string
Age *int // pointer distinguishes "not set" from zero
}
func (u *User) Validate() error {
if u.Name == "" {
return fmt.Errorf("name required")
}
if u.Age != nil && *u.Age < 0 {
return fmt.Errorf("age must be non‑negative")
}
return nil
}
// transport/http/user_handler.go
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req v1.CreateUserRequest
if err := proto.Unmarshal(body, &req); err != nil {
// handle error
}
// Convert to domain object
domainUser := domain.User{
ID: uuid.New(),
Name: req.UserName,
Age: proto.Int32ToPtr(req.Age), // helper converts int32 to *int
}
if err := domainUser.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.UserService.Create(domainUser) // business layer receives a clean domain object
}Benefits :
Domain layer has zero protobuf dependency, enabling isolated unit tests.
Field semantics are explicit (e.g. *int means optional).
Behavior is encapsulated in Validate() attached to the domain object.
Naming freedom: you can use Name instead of the generated UserName.
🎁 Bonus: Protobuf "Correct Usage" Checklist
API request/response – ✅ Define in .proto, use generated structs only for transport.
Internal same‑language service calls – ⚠️ Consider Go interfaces + structs instead of passing .pb.go structs.
Domain model / entity – ❌ Absolutely prohibited to use generated .pb.go structs.
Database schema – ❌ Do not treat .pb.go structs as DB schemas.
Unit‑test inputs – ✅ Acceptable at the transport layer; avoid mocking .pb.go structs in domain tests.
In short, treat protobuf as a contract at the network edge, not as a replacement for well‑designed Go domain models.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Golang Shines
We share daily the latest Golang technical articles, practical resources, language news, tutorials, and real-world projects to help everyone learn and improve.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
