1-1-1 Best Practice
The “1-1-1” best practice advocates structuring definitions with one top-level
entry point (exported message, exported enum, service, or extension) per
.proto file, corresponding to a single proto_library build rule. This
approach promotes small, modular proto definitions. Key benefits include
simplified refactoring, potentially improved build times, and smaller binary
sizes due to minimized transitive dependencies.
Rationale
The 1-1-1 best practice is to keep every proto_library and .proto file as
small as is reasonable, with the ideal ratio being:
- One
proto_librarybuild rule - One source
.protofile - One top-level entry point (
export message,export enum,service, orextend)
Having the fewest number of exported messages, enums, extensions, 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 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.
Symbol Visibility in Edition 2024 and Later
Starting in edition = "2024", Protocol Buffers introduces
Symbol Visibility
with export and local keywords
and the features.default_symbol_visibility option.
Scope Note: Symbol visibility controls only the Protobuf compiler (
protoc) import behavior when resolving references between.protofiles. It has no impact on language-specific generated code or consumers of proto descriptors.
Recommended Setting: STRICT
For all new .proto files in Edition 2024 and later file-option
features.default_symbol_visibility should be set to STRICT. This is the
default in edition 2026:
option features.default_symbol_visibility = STRICT;
In STRICT mode:
- All symbols default to
local. - Only top-level
messageandenumdeclarations intended as entry points may be marked withexport. - Nested symbols cannot use
exportand are enforcedlocal(preventing accidental imports of nested types across file boundaries).
Top-level messages are designed to be atomic with regards to reuse. Nested
messages and enums are inherently private to their parent container. Attempting
to use another message’s nested types as reusable vocabulary subverts the “use
all or none” design of .proto
files. Types intended for independent reuse should be
defined as top-level symbols in dedicated .proto files.
Relaxation for local Helper Symbols
Symbol visibility relaxes strict 1-1-1 for internal helper symbols: a file may
contain multiple local message or local enum definitions, provided they
support a single top-level entry point (forming a single cluster of local
symbols supporting one entry point). Because local symbols cannot be imported
into other .proto files, they do not introduce transitive dependency bloat to
importers.
Service Request and Response Messages
Co-locating a service and its request/response messages in a single file is
acceptable in Edition 2024 if those request and response messages are marked
local (or default to local under STRICT mode). This prevents them from
being imported into other .proto files and avoids pulling the service
definition and its gRPC dependencies into unrelated build
targets.
However, if a request or response message ever needs to be shared or imported
outside of the service file (for example, for logging, data retention, or reuse
in another RPC), it should be defined as a top-level export message in its own
dedicated .proto file.
Modular Schema Example
One place that modularity of proto schema files is important is when creating gRPC definitions. The following set of proto files shows modular structure in Edition 2024.
student_id.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
export message StudentId {
string value = 1;
}
full_name.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
export message FullName {
string family_name = 1;
string given_name = 2;
}
student.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
import "student_id.proto";
import "full_name.proto";
export message Student {
StudentId id = 1;
FullName name = 2;
}
get_student_request.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
import "student_id.proto";
import "external/expensive/dependency.proto";
// Export and stand-alone because it is used in both
// student_request_log.proto AND student_service.proto.
export message GetStudentRequest {
StudentId id = 1;
expensive.external.OtherMessage message = 2;
}
student_request_log.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
import "get_student_request.proto";
// log any requests made.
message StudentRequestLog {
string requesting_user = 1;
GetStudentRequest request = 2;
}
student_service.proto
edition = "2024";
package my.package;
option features.default_symbol_visibility = STRICT;
// imported because we log this message in student_request_log.proto meaning the
// message needs to be in its own file and `export`.
import "get_student_request.proto";
local message GetStudentResponse {
Student student = 1;
}
local message CreateStudentRequest {
FullName name = 1;
}
local message CreateStudentResponse {
Student student = 1;
}
service StudentService {
rpc CreateStudent(CreateStudentRequest) returns (CreateStudentResponse);
rpc GetStudent(GetStudentRequest) returns (GetStudentResponse);
}
The service definition and 3 of its 4 message definitions are defined in
student_service.proto. Those messages are all marked local making it safe to
co-locate with the service definition without introducing unnecessary
dependencies. GetStudentRequest is shared by the StudentService and
StudentRequestLog, and so it should be in its own file.
In this example, Student, StudentId, and FullName are public domain types
that are reusable in logs, requests, responses and external protos.
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.