1-1-1 Best Practice

All proto definitions should have one top-level element and build target per file.

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_library build rule
  • One source .proto file
  • One top-level entry point (export message, export enum, service, or extend)

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 .proto files. 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 message and enum declarations intended as entry points may be marked with export.
  • Nested symbols cannot use export and are enforced local (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.