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 .