All notes

Why a Protobuf schema feels more important than generated code

Generated Protobuf code makes messages convenient to use in one programming language, but the schema carries the enduring contract, wire identity and compatibility decisions.

about 35 minutes min read

The first time I generated Go code from a Protocol Buffers file, the generated file looked like the important part.

I had written something small:

syntax = "proto3";

package catalog.v1;

message GetProductRequest {
  string product_id = 1;
}

Then the Protocol Buffer compiler produced something considerably larger.

The generated Go contained concrete message types, field accessors, reflection metadata, serialisation and deserialisation support, and descriptor information.

My tiny schema had become hundreds of lines of working code.

That made the generated file feel substantial and the .proto file feel like an input form I had completed to obtain it.

But that interpretation reverses the relationship.

The generated code is one language-specific expression of the schema.

                         SCHEMA
                            │
           ┌────────────────┼────────────────┐
           │                │                │
           ▼                ▼                ▼
       Go code          Java code        Python code

If I regenerate the Go code using compatible tooling, I can reproduce the language bindings.

If I lose the schema and retain only one generated output, I have lost the clearest authored source of the shared contract.

That leads to the distinction I am beginning to keep:

Generated code helps one program use a Protobuf contract. The schema is the authored structural core of the contract those programs share.

The qualification matters. A schema can define messages, field identities, packages, services and RPC shapes. It does not, by itself, specify every behavioural guarantee. Idempotency, deadlines, authorisation, durability, retry policy and business validation still need documentation, policy, tests and implementation.

The schema is not the whole distributed contract.

It is the part that gives the contract durable structure.

Current note: this article uses syntax = "proto3" because that was the common language model when it was written. Protobuf Editions now provide the evolving language model intended to replace the proto2 and proto3 designations. Editions use feature settings to control behaviours such as field presence, while existing proto3 schemas remain useful and widely understood.

1. The schema is the authored source

A .proto file can define:

  • messages and fields;
  • field numbers;
  • enumerations;
  • services and RPC methods;
  • package names;
  • language-generation options.

For example:

syntax = "proto3";

package catalog.v1;

service CatalogService {
  rpc GetProduct(GetProductRequest)
      returns (GetProductResponse);
}

message GetProductRequest {
  string product_id = 1;
}

message GetProductResponse {
  Product product = 1;
}

message Product {
  string product_id = 1;
  string name = 2;
  Money price = 3;
}

message Money {
  int64 amount_minor = 1;
  string currency = 2;
}

From that schema, tooling can generate code for several languages.

catalog.proto
     │
     ├──► Go message types and gRPC interfaces
     ├──► Java message classes and gRPC interfaces
     ├──► Python message classes and gRPC interfaces
     └──► descriptor data used by tooling

The generated APIs look different because the target languages are different.

Go might expose structs, methods and interfaces. Java might expose classes and builders. Python might expose dynamic-looking message objects.

The languages do not need identical programming interfaces.

They need to interpret the same message and service structure compatibly.

The common source is the schema.

Generated code is an implementation artefact

Generated code is valuable. It prevents every application team from independently implementing:

  • binary encoding and decoding;
  • field access;
  • unknown-field handling;
  • RPC client plumbing;
  • RPC server plumbing.

It converts a language-neutral schema into types that feel natural enough inside a chosen language.

A Go application may write:

request := &catalogv1.GetProductRequest{
	ProductId: "chair-42",
}

A Java application may use a builder:

GetProductRequest request =
    GetProductRequest.newBuilder()
        .setProductId("chair-42")
        .build();

The syntax differs.

The logical message remains:

GetProductRequest

field 1:
    product_id = "chair-42"

The generated output is tied to one programming language, one Protobuf runtime, one compiler version, one generation plugin and one set of options.

The schema sits above those choices.

Generated files should remain reproducible outputs

Suppose I dislike the name of a generated accessor and edit the generated file directly.

Regeneration may erase the change:

manual edit
    │
    ▼
run code generator
    │
    ▼
manual edit disappears

The modification also does not change the shared schema. A Java client, Python client or another Go service still generates its bindings from the unchanged .proto file.

If the generated output is unsuitable, the meaningful questions are:

  • Should the schema change?
  • Should a language-specific generation option change?
  • Should application code wrap the generated type?
  • Is this business behaviour that belongs outside the transport message?

Application-specific behaviour can live in handwritten code around the generated types.

Generated files should normally remain reproducible outputs rather than new sources of truth.

2. Field numbers create durable identity

Consider:

message Product {
  string product_id = 1;
  string name = 2;
  int64 price_minor = 3;
}

The numbers are not decorative ordering labels.

They identify fields in the binary wire format.

Conceptually, an encoded field carries:

field number
+
wire type
+
encoded value

The binary message does not need to write the full field name price_minor into every record. It can encode the field identified by number 3.

That makes field numbers part of the durable wire contract.

Schema element Main role
Field name Human meaning, generated APIs, JSON and text representations
Field number Binary wire identity

Renumbering fields is therefore not cosmetic housekeeping.

Changing:

string name = 2;

to:

string name = 7;

does not simply move name further down the file.

From the wire format’s perspective, field 2 was removed and a new field 7 was introduced. Existing data may still contain field number 2, and older programs may still expect it.

The neatness of the source file is not worth breaking the protocol.

Source order is not wire identity

Suppose the schema begins as:

message Product {
  string product_id = 1;
  string name = 2;
  int64 price_minor = 3;
}

I can reorganise its source order:

message Product {
  int64 price_minor = 3;
  string product_id = 1;
  string name = 2;
}

The field numbers have not changed.

The wire identity remains the same.

The contract is not “first field, second field, third field.” It is:

  • field 1 is product_id;
  • field 2 is name;
  • field 3 is price_minor.

The numbers endure independently of how I arrange the source for human readability.

Deleted fields should remain retired

Suppose the schema originally contains:

message Product {
  string product_id = 1;
  string name = 2;
  string legacy_description = 3;
}

Later, legacy_description is no longer used.

It may be tempting to reuse number 3:

message Product {
  string product_id = 1;
  string name = 2;
  string supplier_reference = 3;
}

But older serialised messages may still contain field 3 using the old meaning. An older producer might still send it. A newer reader could interpret an old description as a supplier reference.

The correct approach is to reserve the retired identity:

message Product {
  reserved 3;
  reserved "legacy_description";

  string product_id = 1;
  string name = 2;
  string supplier_reference = 4;
}

The reservation records architectural history:

  • field number 3 had a meaning;
  • that meaning was removed;
  • number 3 must not acquire a different meaning.

The field may be gone.

Its number still has a past.

The schema is a compatibility record

A mature schema can contain both its current fields and selected history:

message Product {
  reserved 3, 6;
  reserved "legacy_description", "old_category";

  string product_id = 1;
  string name = 2;
  Money price = 4;
  ProductStatus status = 5;
}

Those reservations may look like clutter to somebody reading only the latest version.

They are evidence that the schema has a lifecycle.

The contract remembers enough of its past to prevent future code from accidentally reinterpreting old data.

Generated code usually represents the current schema.

The schema can preserve current meaning and compatibility history together.

3. Compatibility is more than successful parsing

Adding a field is usually easier than changing an existing one.

Suppose version one defines:

message Product {
  string product_id = 1;
  string name = 2;
}

Version two adds:

message Product {
  string product_id = 1;
  string name = 2;
  string description = 3;
}

An older binary reader does not know field 3. It can treat that field as unknown. A newer reader receiving an older message simply sees no value for description.

NEW PRODUCER → OLD CONSUMER

field 1 understood
field 2 understood
field 3 unknown


OLD PRODUCER → NEW CONSUMER

field 1 understood
field 2 understood
field 3 absent

This supports gradual rollout when the schema change is structurally compatible and the application semantics tolerate missing information.

Unknown fields help binary version overlap

Imagine a newer producer adds:

string description = 3;

An older intermediary receives the binary message.

It cannot expose a generated description accessor because its generated code predates the field. A conforming binary runtime can still retain that unknown field while parsing and serialising the same message again.

NEW PRODUCER
    │
    │ message includes field 3
    ▼
OLD INTERMEDIARY
    │
    │ does not understand field 3
    │ preserves unknown binary field
    ▼
NEW CONSUMER
    │
    └── can still receive field 3

That guarantee depends on the path.

ProtoJSON does not preserve unknown fields. Passing a binary message through ProtoJSON can therefore discard information that an older binary intermediary might otherwise retain. ProtoJSON also places field and enum names in the representation, which gives it weaker evolution properties than the binary format.

Application code can also lose unknown fields by constructing a new message instead of forwarding the parsed one.

Unknown-field preservation is useful.

It does not remove the need to understand the complete data path.

Wire compatibility is not semantic compatibility

Suppose I change:

int64 price_minor = 3;

into:

int64 warehouse_quantity = 3;

The wire type remains an integer.

The meaning does not.

Older data containing:

field 3 = 24900

was a price. The new application would interpret it as stock quantity.

The bytes decode successfully.

The system becomes absurd.

Compatibility has several layers:

Compatibility layer Question
Wire Can older and newer implementations parse the bytes?
Generated source Will existing callers still compile and behave?
Semantic Does the value still mean what consumers believe it means?

Protobuf tooling can detect many structural incompatibilities.

It cannot determine whether I have changed the business meaning of a field.

Names still matter

Binary Protobuf uses field numbers, so renaming a field can be wire-compatible.

Changing:

string product_id = 1;

to:

string item_id = 1;

does not change binary field number 1.

But names appear in:

  • generated source-code APIs;
  • ProtoJSON field names;
  • Text Format representations;
  • documentation;
  • queries and tooling;
  • developers’ understanding of meaning.

A rename may therefore break source code or non-binary consumers even when the binary wire format remains compatible.

The schema participates in several interfaces at once.

Existing field types should be treated as fixed

Changing:

int32 quantity = 2;

to:

string quantity = 2;

changes the wire representation and is plainly unsafe.

Some numeric type changes share a wire representation, but they can still alter range, signedness, generated APIs and semantics.

The practical rule is stronger than “check whether the wire type matches”:

Treat an existing field’s type as fixed. Even changes that appear wire-compatible can be difficult to roll out safely.

A type declaration determines how values cross time, languages and deployments.

It is not merely a generation preference.

New enum values require tolerant consumers

Proto3 enums use an open-enum model. A newer producer can send a numeric enum value that an older consumer did not know when its code was generated.

That can be wire-compatible, but application code must still behave sensibly.

A consumer should not assume:

  • every numeric value has one of the names known at compile time;
  • every switch remains exhaustive forever;
  • every validator should reject future values automatically.

Generated-language details can vary, especially when schemas cross proto2, proto3 and Editions boundaries.

The durable design principle is:

Consumers must be prepared to receive enum values introduced after they were generated.

Adding an enum member may preserve the wire contract while still breaking rigid application logic.

4. Presence and defaults are modelling decisions

Consider:

message UpdateProductRequest {
  string name = 1;
}

What does an empty string mean?

  • the caller wants to clear the name;
  • the caller did not provide a name;
  • the generated API returned a default value.

Those are different intentions.

For update-style APIs, presence matters.

With explicit presence in proto3:

message UpdateProductRequest {
  optional string name = 1;
}

the generated API can distinguish:

  • field absent;
  • field present with an empty value.

That may allow the service to interpret:

Representation Possible meaning
name absent Leave the existing name unchanged
name present and empty Attempt to set an empty name, perhaps rejected by validation

The schema requested presence, so generated code can expose presence checks.

The modelling decision lives in the schema.

optional here controls presence tracking.

It does not mean that the application considers the field optional in its business rules. A create operation can still require a present, valid name through application validation.

Default values can hide absence

Proto3 scalar fields without explicit presence expose their type’s default value when absent:

Type Default
string ""
integer 0
bool false
enum zero-valued member

Suppose:

message StockLevel {
  uint32 available_quantity = 1;
}

Receiving 0 may mean:

  • the producer explicitly reported no stock;
  • the field was absent.

If the distinction matters, the schema must model it.

Possible approaches include:

optional uint32 available_quantity = 1;

or a separate message that represents the state more explicitly.

Generated code cannot recover a distinction that the schema chose not to encode.

The zero enum value should be neutral

Consider:

enum ProductStatus {
  PRODUCT_STATUS_ACTIVE = 0;
  PRODUCT_STATUS_DRAFT = 1;
  PRODUCT_STATUS_DISCONTINUED = 2;
}

If the field is absent, generated code may expose the zero value PRODUCT_STATUS_ACTIVE.

The system could accidentally interpret an unspecified status as active.

A safer design begins with:

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_DRAFT = 1;
  PRODUCT_STATUS_ACTIVE = 2;
  PRODUCT_STATUS_DISCONTINUED = 3;
}

Now the default state is visibly incomplete.

Application validation can reject it where a meaningful status is required.

UNSPECIFIED is not automatically ACTIVE.

Generated code faithfully exposes the enum designed in the schema. It cannot make a risky zero value safe afterwards.

Boolean fields can close future options

Suppose I define:

bool available = 1;

Today the states appear to be available and unavailable.

Later, the domain may need:

  • available;
  • unavailable;
  • preorder;
  • backorder;
  • temporarily unknown;
  • not sold in this region.

The Boolean captured an early binary assumption.

An enum might provide a more evolvable model:

enum AvailabilityStatus {
  AVAILABILITY_STATUS_UNSPECIFIED = 0;
  AVAILABILITY_STATUS_AVAILABLE = 1;
  AVAILABILITY_STATUS_UNAVAILABLE = 2;
  AVAILABILITY_STATUS_BACKORDER = 3;
}

Not every Boolean is wrong. Some concepts genuinely have two durable states.

The schema deserves domain-level thought because its early shortcuts can become long-lived protocol constraints.

5. Design messages around contracts and ownership

A schema should describe the supported boundary, not merely mirror database tables.

Suppose the catalog database contains:

  • products;
  • product_prices;
  • product_descriptions;
  • product_categories.

The API does not need to publish one Protobuf message per table:

message ProductRow {
  // Mirrors products table.
}

message ProductPriceRow {
  // Mirrors product_prices table.
}

That would make the database layout part of the remote contract.

Instead, the service can expose a message designed around consumer needs:

message PurchasableProduct {
  string product_id = 1;
  string name = 2;
  Money current_price = 3;
  AvailabilityStatus availability = 4;
}

Internally, catalog may assemble that response from several tables.

DATABASE MODEL
      │
      ├── optimised for storage and constraints
      │
      ▼
APPLICATION MODEL
      │
      ├── expresses catalog behaviour
      │
      ▼
PROTOBUF API MODEL
      │
      └── expresses the supported remote contract

These models may resemble one another.

They do not have to be identical.

A Protobuf schema becomes difficult to evolve when it exports persistence internals.

Different messages have different lifecycles

A message persisted for years has different pressures from one used for a short-lived RPC request.

Stored data may need to remain readable across several releases, runtime upgrades, long retention periods and historical reprocessing.

An RPC request may be relevant only during one operation.

Reusing one message everywhere can couple those lifecycles:

message Product {
  // Used by database storage,
  // public API,
  // Kafka events,
  // cache values,
  // internal RPCs.
}

A convenient shared message can become impossible to change because every use has different compatibility requirements.

More deliberate messages might include:

  • GetProductResponse;
  • ProductCreatedEvent;
  • ProductSearchDocument;
  • StoredProductRecord.

They may share concepts.

They represent different contracts.

Generated code makes reuse easy.

Schema design should decide whether reuse is wise.

Requests and responses clarify authority

Suppose an RPC uses the domain message directly:

rpc CreateProduct(Product) returns (Product);

This creates several ambiguities.

Does the client supply the product ID, publication status, server timestamps, calculated fields or internal revision?

A dedicated request can express caller-owned input:

message CreateProductRequest {
  string name = 1;
  Money initial_price = 2;
}

A dedicated response can express the server-owned result:

message CreateProductResponse {
  Product product = 1;
}

The distinction makes authority clearer.

Message Meaning
Request What the caller is allowed to propose
Response What the service accepted and now reports

The schema does more than produce classes.

It communicates ownership across the boundary.

Avoid the universal model

Because Protobuf makes messages reusable, it is tempting to create:

message UniversalProduct {
  // Every field required by every system.
}

Catalog, basket, order, search and analytics could all import it.

But those systems need different meanings:

Boundary Product meaning
Catalog Current product presentation
Basket Product summary currently selected
Order Purchase-time product snapshot
Search Indexable product projection
Analytics Historical reporting dimensions

A single shared message can couple all of those models.

Changing it requires considering every consumer, even when the change belongs to only one boundary.

Sometimes a little duplication between schemas preserves a much more valuable independence of meaning.

6. Services contain structure, not every guarantee

With gRPC, a .proto file can define remote operations:

service CatalogService {
  rpc GetProduct(GetProductRequest)
      returns (GetProductResponse);

  rpc CreateProduct(CreateProductRequest)
      returns (CreateProductResponse);
}

Generated Go code can include client and server interfaces, registration functions and method descriptors.

Those artefacts make implementation convenient.

The schema defines:

  • service name;
  • method name;
  • request type;
  • response type;
  • unary or streaming shape.

Changing a unary RPC to a streaming method is not merely a generated function-signature change.

It changes the interaction contract.

Behaviour still needs an explicit home

A method definition such as:

rpc ReserveStock(ReserveStockRequest)
    returns (ReserveStockResponse);

does not tell me:

  • whether the method is idempotent;
  • which errors are retryable;
  • which deadline is appropriate;
  • whether success means a durable reservation;
  • how long the reservation lasts;
  • whether calls must be ordered;
  • which identities are authorised.

Some of these semantics can be represented through comments, custom options, validation rules, API documentation, service policy and tests.

The basic schema alone is not the whole contract.

It is the structural core around which behavioural guarantees must be documented and enforced.

Comments are part of the human interface

Compare:

string status = 4;

with:

// Current publication status of the product.
//
// Consumers must not treat an unknown value as published.
ProductStatus status = 4;

The second definition communicates more than its wire type.

Good comments can explain:

  • field meaning;
  • units;
  • ownership;
  • validation expectations;
  • privacy sensitivity;
  • deprecation;
  • whether values are snapshots or current state.

For example:

message Money {
  // Amount in the currency's minor unit.
  //
  // For GBP, 24900 represents £249.00.
  int64 amount_minor = 1;

  // ISO 4217 currency code, such as "GBP".
  string currency = 2;
}

Generated documentation can surface those comments.

The schema becomes readable by both tools and humans.

Names spread efficiently

A field named:

int64 value = 1;

is structurally valid.

It makes every consumer ask:

  • value of what?
  • which unit?
  • captured when?
  • authoritative where?

A better schema might use:

int64 amount_minor = 1;

or:

uint32 requested_quantity = 1;

The name is part of the human contract.

Generated code reproduces it in each language’s naming conventions.

Poor names therefore spread efficiently.

Code generation is a highly productive photocopier. It does not improve the source document placed upon it.

Packages create schema identity

A schema can declare:

package catalog.v1;

The package separates catalog messages from similarly named messages elsewhere:

  • catalog.v1.Product;
  • search.v1.Product;
  • order.v1.ProductSnapshot.

Those types may all refer to one real-world item.

They represent different bounded meanings.

The version component can also make incompatible API generations explicit:

  • catalog.v1;
  • catalog.v2.

That does not mean every change requires a new package version. Compatible additions can usually remain within the existing version.

A new major package can be reserved for deliberately incompatible contracts requiring parallel support and migration.

Versioning should express compatibility policy, not become a ritual attached to every commit.

Language-specific options sit at another layer:

option go_package =
    "example.com/bfstore/gen/catalog/v1;catalogv1";

option java_package =
    "com.example.bfstore.catalog.v1";
Namespace Purpose
Protobuf package Identity within the schema universe
Language package option Placement and naming in one generated ecosystem

One schema can fit several language conventions without surrendering its language-neutral identity.

7. Generation and tooling should be reproducible

If generated code is an output, I should know how it was produced.

That includes:

  • source schemas;
  • compiler version;
  • language plugin versions;
  • generation options;
  • dependency versions;
  • exact generation command.

A reproducible workflow might provide one command:

buf generate

or:

make proto

The repository can define which schemas are inputs, which plugins run, where output is written and which versions are expected.

Whether generated files are committed to source control depends on the project and language ecosystem.

Either way, the .proto source and generation configuration remain essential.

The generated output should not be the only surviving record of how the contract was defined.

Automated checks can protect structural compatibility

A code review may miss that somebody:

  • reused a deleted field number;
  • changed a field type incompatibly;
  • removed a method;
  • changed a package;
  • renamed something that breaks generated APIs or JSON consumers.

Tooling can compare the proposed schema with a previous version and identify many structural breaking changes.

For example:

buf breaking \
    --against '.git#branch=main'

A breaking-change check does not decide that breaking changes are forbidden forever.

A project may have no external consumers, a controlled migration, one coordinated deployment or a deliberate new major version.

The tool identifies contract impact.

Humans decide whether the impact is acceptable and how it will be managed.

Automated checks also cannot understand the semantics of every custom option or behavioural annotation. Buf’s built-in breaking checks, for example, do not generally validate changes to custom options because their meaning is application-specific.

This is another sign that the schema is a first-class artefact.

We compare its compatibility over time, not merely whether today’s generated code compiles.

Linting supports consistency, not meaning

Schema linting can enforce conventions around package, message, field, enum, service and file naming.

Consistency makes a large schema easier to navigate and reduces accidental differences between teams.

But a linter cannot determine whether:

string customer = 1;

means customer ID, customer name, customer email or a serialised customer object.

Mechanical consistency supports semantic design.

It does not replace it.

Descriptors are another compiled view

The compiler can represent a Protobuf schema using descriptors.

A descriptor contains machine-readable information about files, packages, messages, fields, enums, services and methods.

That enables reflection, dynamic clients, documentation generation, schema inspection and generic tooling.

.proto source
      │
      ├──► Go generated code
      ├──► Java generated code
      ├──► Python generated code
      └──► descriptor set

The descriptor is a compiled representation of the contract.

The .proto remains the clearest authored source.

8. The schema belongs to its boundary

Suppose catalog begins in Go.

catalog.proto
      │
      ▼
Go catalog service

Later, a data-processing consumer is written in Python.

catalog.proto
      │
      ├──► Go service bindings
      └──► Python consumer bindings

The service implementation may eventually be rewritten.

The contract can remain stable.

Consumers should not need to care which language currently implements catalog.

That independence is one of the strongest reasons the schema matters.

The generated code belongs to an implementation.

The schema belongs to the boundary.

The owner controls compatibility decisions

If catalog owns the CatalogService contract, catalog should control:

  • schema changes;
  • compatibility decisions;
  • field meaning;
  • service semantics;
  • deprecation policy;
  • release process.

Consumers should be able to propose changes and explain their needs.

They should not independently fork the schema and create competing definitions.

CATALOG CONTRACT

one authoritative source


CONSUMERS

generate compatible bindings
from that source

A copied .proto file that quietly diverges is no longer a shared contract.

It is two contracts with the same family portrait.

Schema distribution should preserve one authoritative definition and traceable versions.

Generated code can create a false sense of completeness

Once the compiler produces client interfaces, server interfaces, message classes and serialisation functions, the API can feel finished.

But several questions remain:

  • What does each operation mean?
  • Which fields are required by business rules?
  • Which values are valid?
  • Who owns each identifier?
  • Which errors can occur?
  • Which methods are idempotent?
  • How are deadlines chosen?
  • Which changes are backward compatible?
  • What happens when old and new versions overlap?

Generated code answers:

How does this language construct and exchange the defined message?

It does not answer:

Is this a good distributed contract?

The schema gives structural design decisions somewhere explicit to live.

The team still has to make the wider decisions.

A schema review checklist

Concern Review question
Meaning Does every message and field have one clear purpose?
Ownership Which service owns the fact represented here?
Identity Are field numbers stable and unique?
Presence Must absent be distinguishable from a default value?
Defaults Is the zero enum value safe and visibly unspecified?
Evolution Can compatible fields be added without breaking old consumers?
Deletion Are removed field numbers and names reserved?
Units Are money, time, size and quantity units explicit?
Boundaries Is this a supported API contract rather than a database-table export?
Reuse Does this message genuinely have one lifecycle?
Behaviour Are validation, error, retry and idempotency semantics documented?
Generation Can every generated artefact be reproduced?
Compatibility Does automated checking protect the schema during review?
Unknowns Can consumers tolerate fields and enum values added later?
Representations Does any JSON or text path weaken the binary compatibility assumptions?

The generated code can be regenerated after the review.

The schema is what needs the architectural attention.

A possible bfstore schema

A catalog contract might begin:

syntax = "proto3";

package bfstore.catalog.v1;

option go_package =
    "github.com/mantrobuslawal/bfstore/gen/catalog/v1;catalogv1";

service CatalogService {
  rpc GetProduct(GetProductRequest)
      returns (GetProductResponse);
}

message GetProductRequest {
  string product_id = 1;
}

message GetProductResponse {
  Product product = 1;
}

message Product {
  string product_id = 1;
  string name = 2;
  string description = 3;
  Money current_price = 4;
  ProductStatus status = 5;
}

message Money {
  int64 amount_minor = 1;
  string currency = 2;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_DRAFT = 1;
  PRODUCT_STATUS_PUBLISHED = 2;
  PRODUCT_STATUS_DISCONTINUED = 3;
}

Generating Go code gives the catalog service and its clients convenient types.

Generating Python code gives an operator tool compatible message types.

Generating Java code could support another consumer later.

But the .proto file remains where the shared decisions are visible:

  • product ID is field 1;
  • name is field 2;
  • price is represented using amount and currency;
  • status has an explicit unspecified value;
  • the service method accepts one request type and returns one response type.

Those decisions must remain compatible even when the generated implementations change.

The mental model I am keeping

My original model was:

.proto file
     │
     ▼
generated code
     │
     ▼
important result

The new model is:

                         PROTOBUF SCHEMA
                                │
               ┌────────────────┼────────────────┐
               │                │                │
               ▼                ▼                ▼
          wire contract    API structure    compatibility history
               │                │                │
               └────────────────┼────────────────┘
                                ▼
                       CODE GENERATION
                                │
          ┌─────────────────────┼─────────────────────┐
          │                     │                     │
          ▼                     ▼                     ▼
       Go bindings          Java bindings       Python bindings
          │                     │                     │
          └─────────────────────┼─────────────────────┘
                                ▼
                     application implementations

The generated code is essential machinery.

But it is replaceable machinery.

The schema contains the structural decisions that must survive replacement:

  • which messages exist;
  • what their fields mean;
  • which numeric identities belong to those fields;
  • which changes remain compatible;
  • which operations one service exposes;
  • which historical identities must never be reused.

This changes how I should review Protobuf work.

I should not begin with:

Does the generated Go code look convenient?

I should begin with:

  • Is the contract clear?
  • Can it evolve?
  • Does it preserve ownership?
  • Have I modelled absence correctly?
  • Are the field numbers safe?
  • Will older and newer participants coexist?
  • Does the schema expose domain meaning rather than implementation accidents?
  • Are the behavioural guarantees documented outside the basic schema where necessary?

Then I can generate the code.

And regenerate it.

And generate it for another language.

The code helps each program speak.

The schema is the agreement about what they are saying.

References and further reading

Protocol Buffers language and schema design

Protocol Buffers: Proto3 Language Guide Documents .proto files, messages, fields, field numbers, generated code, unknown fields, reserved identifiers, enums, services and schema evolution.

Protocol Buffers: Language Specification for Proto3 Defines the grammar of proto3 schemas, including messages, fields, services, enums, packages and reserved declarations.

Protocol Buffers: Proto Best Practices Provides guidance on stable field numbers, reserving deleted tags, avoiding field-type changes, enum design and separation between API and storage messages.

Protocol Buffers: Protobuf Editions Overview Explains the Editions language model, feature-based behaviour and the relationship between Editions, proto2 and proto3.

Binary and JSON encoding

Protocol Buffers: Encoding Explains how field numbers and wire types are encoded into Protobuf’s binary wire format.

Protocol Buffers: ProtoJSON Format Explains the canonical JSON mapping and its weaker schema-evolution properties, including the lack of unknown-field preservation.

Presence and enums

Protocol Buffers: Application Note on Field Presence Explains implicit and explicit field presence, proto3 optional fields and the difference between an absent field and a field containing its default value.

Protocol Buffers: Enum Behaviour Explains open and closed enum behaviour and how unknown enum values are represented across schema types and language implementations.

Generated code and descriptors

Protocol Buffers: Go Generated Code Guide Documents how Protobuf message and field definitions are represented in generated Go code.

Protocol Buffers: Java Generated Code Guide Shows how the same schema concepts become Java classes, builders, accessors and field-number constants.

Schema compatibility tooling

Buf: Breaking Change Detection Explains structural comparison of Protobuf schemas against an earlier version.

Buf: Breaking Change Detection Usage Documents practical breaking-change configuration and limitations, including custom-option changes that built-in checks cannot interpret.

Buf: Linting Documents schema linting rules for consistent packages, messages, fields, enums and service definitions.