Build CoreOrdered learning track

Protocol Buffers Contract Design

Learn Java Microservices Communication - Part 050

Protocol Buffers contract design for Java microservices: message modeling, field numbering, compatibility, reserved fields, enums, optional fields, oneof, package naming, API evolution, validation, documentation, and CI gates.

5 min read999 words
PrevNext
Lesson 5096 lesson track18–52 Build Core
#java#microservices#communication#grpc+4 more

Part 050 — Protocol Buffers Contract Design

Protocol Buffers are not just serialization.

In gRPC systems, .proto files are API contracts.

A good .proto contract is:

  • explicit,
  • evolvable,
  • backward-compatible,
  • readable,
  • stable,
  • testable,
  • owned.

A bad .proto contract becomes a distributed breaking-change machine.

The core rule:

field numbers are forever

Names may change in generated code.

Field numbers are what the wire format uses.

Treat them as part of public API.


1. Proto File Structure

Example:

syntax = "proto3";

package example.case.v1;

option java_multiple_files = true;
option java_package = "com.example.case.v1";
option java_outer_classname = "CaseApiProto";

service CaseQueryService {
  rpc GetCase(GetCaseRequest) returns (GetCaseResponse);
}

message GetCaseRequest {
  string case_id = 1;
}

message GetCaseResponse {
  Case case = 1;
}

message Case {
  string case_id = 1;
  string status = 2;
  int64 version = 3;
}

Design rules:

  • package includes domain and version,
  • Java package explicit,
  • service names stable,
  • request/response types specific,
  • field names meaningful,
  • field numbers intentionally assigned.

2. Field Numbering

Never reuse field numbers.

Bad:

message Case {
  string old_status = 2; // removed
  string priority = 2;   // reused - dangerous
}

Good:

message Case {
  reserved 2;
  reserved "old_status";

  string priority = 4;
}

Reusing field numbers can cause old clients to interpret new data incorrectly.

Reserve removed fields.


3. Compatibility Rules

Safer changes:

  • add new field with new number,
  • add optional information consumers can ignore,
  • add new enum value carefully,
  • add new RPC method,
  • add new message type,
  • deprecate field without removing immediately.

Dangerous changes:

  • remove required semantic field,
  • reuse field number,
  • change field type incompatibly,
  • change field meaning,
  • rename package/service incompatibly,
  • change enum numeric value,
  • change request/response semantics,
  • remove RPC method.

Schema compatibility and semantic compatibility are both required.


4. Message Design

Use domain-focused messages.

Bad:

message GenericRequest {
  string action = 1;
  map<string, string> params = 2;
}

Better:

message EscalateCaseRequest {
  string case_id = 1;
  string reason = 2;
  string target_queue = 3;
  string idempotency_key = 4;
}

Typed messages are the reason to use Protobuf.

Do not recreate untyped JSON inside Protobuf.


5. Request and Response Naming

Prefer explicit request/response messages:

rpc GetCase(GetCaseRequest) returns (GetCaseResponse);
rpc EscalateCase(EscalateCaseRequest) returns (EscalateCaseResponse);

Avoid reusing one large message across many methods.

Method-specific messages allow evolution without coupling unrelated operations.


6. Enum Design

Always include unspecified value at zero.

enum CaseStatus {
  CASE_STATUS_UNSPECIFIED = 0;
  CASE_STATUS_OPEN = 1;
  CASE_STATUS_ESCALATED = 2;
  CASE_STATUS_CLOSED = 3;
}

Rules:

  • zero value means unspecified/unknown,
  • never reorder numeric values,
  • never reuse numeric values,
  • handle unknown enum values in clients,
  • avoid using enum for highly dynamic business taxonomy.

Enum evolution can break clients if they assume exhaustive known values.


7. Optional Fields

In proto3, field presence matters when you need to distinguish:

not provided
provided as default value

Use optional where presence is semantically important.

Example:

message UpdateCaseRequest {
  string case_id = 1;
  optional string assignee_id = 2;
}

Do not overload empty string to mean "not present" if empty string may be valid or ambiguous.


8. oneof

Use oneof when exactly one variant is allowed.

message CaseEvent {
  string event_id = 1;

  oneof payload {
    CaseCreated case_created = 10;
    CaseEscalated case_escalated = 11;
    CaseClosed case_closed = 12;
  }
}

Be careful when evolving oneof:

  • adding a new variant may be safe,
  • moving fields into/out of oneof is dangerous,
  • clients must handle unknown variants.

9. Maps and Repeated Fields

Use repeated fields for lists.

repeated string tags = 5;

Use maps for key-value data.

map<string, string> attributes = 6;

Avoid maps for core contract semantics that should be typed.

A map is flexible but weaker for compatibility and documentation.


10. Timestamps and Money

Use well-known types where appropriate.

import "google/protobuf/timestamp.proto";

message Case {
  google.protobuf.Timestamp created_at = 1;
}

For money, avoid floating point.

Use:

message Money {
  string currency = 1;
  int64 units = 2;
  int32 nanos = 3;
}

or an established money type in your organization.

Do not use double for money.


11. Validation

Protobuf itself does not enforce all business validation.

Define validation rules separately or through approved extensions/tools.

Examples:

  • required semantic fields,
  • string length,
  • format,
  • allowed ranges,
  • ID format,
  • repeated max size.

Server must validate requests.

Generated classes do not guarantee valid business command.


12. Error Model

gRPC errors should use status codes and optional structured details.

Define:

  • status code per error,
  • retryable/non-retryable,
  • validation error details,
  • conflict details,
  • not found semantics,
  • internal error hiding.

Example mapping:

missing case_id -> INVALID_ARGUMENT
case not found -> NOT_FOUND
version conflict -> ABORTED
dependency down -> UNAVAILABLE

The error model is part of contract.


13. Package Versioning

Common approach:

package example.case.v1;

When breaking change is unavoidable:

package example.case.v2;

Do not create v2 for every additive change.

Use v2 for semantic breaking changes.

Maintain v1 during migration.


14. Java Options

Recommended:

option java_multiple_files = true;
option java_package = "com.example.case.v1";

Benefits:

  • cleaner generated classes,
  • stable Java package,
  • easier imports,
  • clear version boundary.

Keep generated types at the boundary.

Do not let proto-generated classes become your domain model.


15. Domain Model Boundary

Avoid using generated Protobuf classes deep inside domain logic.

Better:

gRPC request -> application command -> domain model
domain result -> gRPC response

Why:

  • separates transport from domain,
  • easier tests,
  • avoids generated-code coupling,
  • supports HTTP/event adapters,
  • improves validation.

Generated classes are boundary DTOs.


16. Documentation

Proto comments matter.

message EscalateCaseRequest {
  // Stable case identifier.
  string case_id = 1;

  // Idempotency key supplied by caller. Required for safe retry.
  string idempotency_key = 2;
}

Document:

  • field meaning,
  • units,
  • required semantics,
  • default behavior,
  • compatibility notes,
  • deprecation,
  • error behavior.

Contracts need human semantics.


17. CI Gates

CI should check:

  • proto compiles,
  • generated code compiles,
  • breaking changes detected,
  • field numbers not reused,
  • removed fields reserved,
  • lint rules,
  • package naming,
  • service naming,
  • comments for public fields,
  • golden compatibility fixtures,
  • server/client tests.

Do not rely on reviewer memory.

Automate compatibility.


18. Common Anti-Patterns

18.1 Reusing field numbers

Corrupts compatibility.

18.2 Generic map payload

Loses typed contract value.

18.3 Domain model equals proto model

Transport leaks into core.

18.4 No deadlines in method docs

Clients use unsafe defaults.

18.5 Enum without unspecified

Default value ambiguous.

18.6 Breaking package without migration

Clients fail.

18.7 Everything in one proto file

Ownership and evolution become hard.

18.8 No error/status contract

Clients guess.


19. Design Checklist

Before accepting a proto contract:

  • Is package versioned?
  • Are Java options set?
  • Are field numbers stable?
  • Are removed fields reserved?
  • Are messages operation-specific?
  • Are enums designed with unspecified zero?
  • Are optional fields used where presence matters?
  • Are timestamps/money modeled correctly?
  • Are validation rules defined?
  • Are status codes documented?
  • Are comments meaningful?
  • Are compatibility checks in CI?
  • Are generated classes isolated at boundary?

20. The Real Lesson

Protobuf gives you strong contracts only if you design them strongly.

The wire format is efficient.

But the real value is evolvable structure.

Production-grade proto design requires:

stable field numbers
+ reserved removed fields
+ semantic versioning
+ explicit status/error model
+ validation
+ documentation
+ CI compatibility gates

That is how gRPC contracts survive years of service evolution.


References

Lesson Recap

You just completed lesson 50 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.