1-1-1 Best Practice
The 1-1-1 best practice is to keep every proto_library and .proto file as small as is reasonable, with the ideal being:
- One
proto_library
build rule - One source
.proto
file - One top-level entity (message, enum, or extension)
Having the fewest number of message, enum, extension, and services as you reasonably can makes refactoring easier. Moving files when they’re separated is much easier than extracting messages from a file with other messages.
Following this practice can help build times and binary size by reducing the size of your transitive dependencies in practice: when some code only needs to use one enum, under a 1-1-1 design it can depend just on the .proto file that defines that enum and avoid incidentally pulling in a large set of transitive dependencies that may only be used by another message defined in the same file.
There are cases where the 1-1-1 ideal is not possible (circular dependencies), not ideal (extremely conceptually coupled messages which have readability benefits by being co-located), or where the some of the downsides don’t apply (when a .proto file has no imports, then there are no technical concerns about the size of transitive dependencies). As with any best practice, use good judgment for when to diverge from the guideline.
One place that modularity of proto schema files is important is when creating gRPC definitions. The following set of proto files shows modular structure.
student_id.proto
edition = "2023";
package my.package;
message StudentID {
string value = 1;
}
full_name.proto
edition = "2023";
package my.package;
message FullName {
string family_name = 1;
string given_name = 2;
}
student.proto
edition = "2023";
package my.package;
import "student_id.proto";
import "full_name.proto";
message Student {
StudentId id = 1;
FullName name = 2;
}
create_student_request.proto
edition = "2023";
package my.package;
import "full_name.proto";
message CreateStudentRequest {
FullName name = 1;
}
create_student_response.proto
edition = "2023";
package my.package;
import "student.proto";
message CreateStudentResponse {
Student student = 1;
}
get_student_request.proto
edition = "2023";
package my.package;
import "student_id.proto";
message GetStudentRequest {
StudentID id = 1;
}
get_student_response.proto
edition = "2023";
package my.package;
import "student.proto";
message GetStudentResponse {
Student student = 1;
}
student_service.proto
edition = "2023";
package my.package;
import "create_student_request.proto";
import "create_student_response.proto";
import "get_student_request.proto";
import "get_student_response.proto";
service StudentService {
rpc CreateStudent(CreateStudentRequest) returns (CreateStudentResponse);
rpc GetStudent(GetStudentRequest) returns (GetStudentResponse);
}
The service definition and each of the message definitions are each in their own file, and you use includes to give access to the messages from other schema files.
In this example, Student
, StudentID
, and FullName
are domain types that
are reusable across requests and responses. The top-level request and response
protos are unique to each service+method.
If you later need to add a middle_name
field to the FullName
message, you
won’t need to update every individual top-level message with that new field.
Likewise, if you need to update Student
with more information, all the
requests and responses get the update. Further, StudentID
might update to be a
multi-part ID.
Lastly, having even simple types like StudentID
wrapped as a message means
that you have created a type that has semantics and consolidated documentation.
For something like FullName
you’ll need to be careful with where this PII gets
logged; this is another advantage of not repeating these fields in multiple
top-level messages. You can tag those fields in one place as sensitive
and exclude them from logging.