Go Best Practices

Protobuf

API contract conventions that keep validation at the boundary and diffs reviewable .

API versioning

Proto files live under protos/<domain>/v1/ . The package declaration matches the directory : package content.v1 . The go_package option uses an alias format : content/v1;contentv1 .

This versioning scheme carries through the entire codebase . The API handler lives in internal/api/content/v1/ with package name apicontentv1 . The generated Go code lands in gen/sdk/content/v1/ . When v2 arrives , it’s a new directory , a new package , a new set of handlers . No renaming , no breaking existing imports .

Split proto files by concern

One file per concern , not one giant file per domain . This keeps diffs small and reviewable . A change to request validation doesn’t pollute the model diff . A new enum value doesn’t touch the service definition .

content_model.proto holds the resource message and enums :

syntax = "proto3";
package content.v1;

import "google/protobuf/timestamp.proto";

enum ContentStatus {
  CONTENT_STATUS_UNSPECIFIED = 0;
  CONTENT_STATUS_DRAFT = 1;
  CONTENT_STATUS_PUBLISHED = 2;
  CONTENT_STATUS_ARCHIVED = 3;
}

message Content {
  string id = 1;
  string title = 2;
  string body = 3;
  ContentStatus status = 4;
  repeated string tags = 5;
  google.protobuf.Timestamp created_at = 6;
  google.protobuf.Timestamp updated_at = 7;
}

content_refs.proto holds typed ID references for cross-domain use :

syntax = "proto3";
package content.v1;

import "buf/validate/validate.proto";

message ContentRef {
  string id = 1 [(buf.validate.field).string.uuid = true];
}

content_service.proto holds the service definition and request/response pairs :

syntax = "proto3";
package content.v1;

import "buf/validate/validate.proto";
import "google/protobuf/field_mask.proto";
import "content/v1/content_model.proto";

service ContentService {
  rpc CreateContent(CreateContentRequest) returns (CreateContentResponse);
  rpc GetContent(GetContentRequest) returns (GetContentResponse);
  rpc ListContent(ListContentRequest) returns (ListContentResponse);
  rpc UpdateContent(UpdateContentRequest) returns (UpdateContentResponse);
  rpc DeleteContent(DeleteContentRequest) returns (DeleteContentResponse);
}

message CreateContentRequest {
  string title = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}];
  string body = 2 [(buf.validate.field).string.min_len = 1];
  ContentStatus status = 3 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}];
  repeated string tags = 4;
}

message CreateContentResponse {
  Content content = 1;
}

message GetContentRequest {
  string id = 1 [(buf.validate.field).string.uuid = true];
}

message GetContentResponse {
  Content content = 1;
}

message ListContentRequest {
  int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];
  string page_token = 2;
}

message ListContentResponse {
  repeated Content items = 1;
  string next_page_token = 2;
}

message UpdateContentRequest {
  string id = 1 [(buf.validate.field).string.uuid = true];
  Content content = 2 [(buf.validate.field).required = true];
  google.protobuf.FieldMask update_mask = 3 [(buf.validate.field).required = true];
}

message UpdateContentResponse {
  Content content = 1;
}

message DeleteContentRequest {
  string id = 1 [(buf.validate.field).string.uuid = true];
}

message DeleteContentResponse {
  bool success = 1;
}

Validate at the boundary

Use buf.validate annotations on proto fields . String lengths , UUID format , enum ranges , required fields . The interceptor rejects malformed requests before they reach the handler .

This is the mental model in action : validation belongs in the interceptor layer . If your handler is checking whether a string is empty , you missed a proto annotation .

Enum zero is always unspecified

Every enum starts with _UNSPECIFIED = 0 . Proto3 defaults to zero , so an unset enum field should mean “not provided” , not a valid state . Validate it out with not_in: [0] on request fields .

Use FieldMask for updates

Partial updates use google.protobuf.FieldMask to specify which fields to update . This avoids the “zero value vs not set” problem . The caller says exactly what they want to change .

Typed references over raw strings

When one domain references another domain’s entity , use a typed Ref message with UUID validation . Within the same package , plain string id fields are fine . Cross-package references deserve their own type .

For example , a comment domain that references content :

syntax = "proto3";
package comment.v1;

import "content/v1/content_refs.proto";
import "buf/validate/validate.proto";

message CreateCommentRequest {
  content.v1.ContentRef content_ref = 1 [(buf.validate.field).required = true];
  string body = 2 [(buf.validate.field).string.min_len = 1];
}

The ContentRef type carries its own UUID validation . The comment domain doesn’t need to know the format , it just imports the ref and the contract is enforced .

Consistent pagination

page_size with range validation (gte: 1, lte: 100) and page_token / next_page_token . Same pattern everywhere . No offset-based pagination , no custom query parameters .