Protobuf and gRPC Contract Engineering: Tags, Presence, Editions, and Compatibility
Learn Java API Contract Engineering, Event Contract Engineering & Schema Governance - Part 018
Protobuf and gRPC contract engineering for Java: field numbers, presence, proto3, editions, reserved fields, enum evolution, oneof compatibility, service contracts, and generated code behavior.
Part 018 — Protobuf and gRPC Contract Engineering: Tags, Presence, Editions, and Compatibility
Tujuan Pembelajaran
Protobuf sering terlihat sederhana:
message Customer {
string customer_id = 1;
string status = 2;
}
Tetapi contract engineering dengan Protobuf berbeda dari JSON Schema/Avro. Protobuf compatibility berpusat pada field numbers/tags, wire types, presence semantics, default values, reserved fields, enum behavior, oneof, and generated code.
Jika Avro adalah reader/writer schema resolution yang field-name oriented, Protobuf adalah wire-format contract yang tag-number oriented.
Setelah part ini, kamu harus mampu:
- menjelaskan kenapa field number adalah compatibility anchor;
- membedakan field name rename vs field number reuse;
- memakai
reserveduntuk mencegah tag/name reuse; - memahami proto3 default values dan presence;
- memahami proto3
optionaldan Protobuf Editions presence model; - mendesain enum yang evolvable;
- menilai oneof compatibility;
- membedakan Protobuf message contract dan gRPC service contract;
- memahami Java generated code implications;
- membangun review checklist untuk
.protochanges; - memilih Protobuf vs Avro dengan reasoning yang tepat.
1. Mental Model: Field Numbers Are the Contract
Protobuf encoded data carries field numbers, not field names.
Proto:
message CustomerRegistered {
string customer_id = 1;
string registered_at = 2;
}
Wire data roughly says:
field 1 = "cus_123"
field 2 = "2026-06-29T05:00:00Z"
It does not carry:
customer_id
registered_at
Therefore:
Field names are source/schema readability. Field numbers are wire compatibility.
Changing field name while keeping number can be wire-compatible but source/API-breaking for generated code. Reusing old field number for different meaning is catastrophic.
2. Basic Proto Message
syntax = "proto3";
package acme.customer.v1;
option java_multiple_files = true;
option java_package = "com.acme.customer.v1";
option java_outer_classname = "CustomerEventsProto";
message CustomerRegistered {
string event_id = 1;
string customer_id = 2;
int64 registered_at_epoch_millis = 3;
string registration_channel = 4;
}
Key elements:
| Element | Role |
|---|---|
syntax / edition | language mode |
package | Protobuf namespace |
java_package | Java generated package |
message | record/object contract |
| field name | source/generated code name |
| field number | wire tag |
| field type | wire/source type |
| options | codegen/language/tooling behavior |
3. Field Numbers
Rules:
- field numbers must be unique within a message;
- field numbers should never be reused for different meaning;
- low numbers are more compact on wire;
- field numbers 1-15 use fewer bytes than higher numbers;
- reserved range 19000-19999 is for Protobuf implementation;
- maximum field number is very large, but do not use random huge tags casually.
3.1 Good Numbering
message CaseApproved {
string event_id = 1;
string case_id = 2;
int64 case_version = 3;
string approved_by = 4;
int64 approved_at_epoch_millis = 5;
string reason_code = 6;
}
3.2 Bad Numbering
message CaseApproved {
string reason_code = 100000;
string case_id = 499999999;
}
Unnecessary and less readable.
3.3 Numbering Policy
Reserve ranges for future extension if useful:
message CaseApproved {
string event_id = 1;
string case_id = 2;
int64 case_version = 3;
// 100-199 reserved for approval decision details.
string approved_by = 100;
int64 approved_at_epoch_millis = 101;
string reason_code = 102;
}
Do not over-engineer ranges for small messages, but for platform-wide common messages, ranges can help.
4. Never Reuse Field Numbers
Old:
message Customer {
string email_address = 3;
}
Removed later:
message Customer {
reserved 3;
reserved "email_address";
}
Bad future change:
message Customer {
string national_id = 3; // catastrophic
}
Old binary data with field 3 email could be interpreted as national ID by new reader if wire type compatible.
Always reserve removed fields:
message Customer {
reserved 3;
reserved "email_address";
}
5. Reserved Fields and Names
Use reserved to prevent future reuse.
message Customer {
reserved 3, 7, 9 to 11;
reserved "email_address", "legacy_status";
string customer_id = 1;
string display_name = 2;
}
Reserve both number and name when removing/renaming.
Why reserve name too?
- avoids reintroducing old generated accessor names with different meaning;
- prevents JSON/text-format confusion;
- documents history;
- helps code reviewers.
6. Field Rename
Rename field name but keep number:
Old:
string status = 2;
New:
string lifecycle_status = 2;
Wire-compatible, but source-breaking:
Old Java:
customer.getStatus()
New Java:
customer.getLifecycleStatus()
Generated API changes.
If consumers compile against generated Java class, this is a breaking SDK/source change even if wire-compatible.
Safer migration:
string status = 2 [deprecated = true];
string lifecycle_status = 8;
Then dual-populate if needed, or document mapping.
But duplicate fields can create consistency burden.
Decision depends on whether generated code is consumer-facing.
7. Proto3 Default Values
In proto3, scalar fields have default values when absent:
| Type | Default |
|---|---|
| string | empty string |
| bytes | empty bytes |
| bool | false |
| numeric | zero |
| enum | first enum value, usually zero |
| repeated | empty list |
| message | not set / default instance depending language API |
Problem:
int32 age = 1;
If age is 0, did producer send 0 or omit age? Without presence, you may not know.
This is presence semantics.
8. Field Presence
Presence means you can distinguish:
field absent
from:
field present with default value
This matters for:
- PATCH/update semantics;
- optional business fields;
- nullable-like modeling;
- compatibility;
- generated API behavior.
8.1 Proto3 Implicit Presence
In classic proto3 scalar fields historically had implicit presence:
int32 risk_score = 1;
Generated Java may not have hasRiskScore() for basic scalar field in implicit presence mode.
You cannot tell absent vs zero.
8.2 Proto3 Optional
Proto3 supports optional for explicit presence:
optional int32 risk_score = 1;
Generated Java can expose:
boolean hasRiskScore();
int getRiskScore();
Use optional when absence vs default matters.
8.3 Message Fields Have Presence
message Money {
string currency = 1;
string value = 2;
}
message Payment {
Money amount = 1;
}
Generated Java supports presence for message fields.
8.4 Wrapper Types
Older pattern:
import "google/protobuf/wrappers.proto";
google.protobuf.Int32Value risk_score = 1;
Wrappers provide presence but add object overhead and semantics. With proto3 optional and editions, prefer current idioms unless interoperability requires wrappers.
8.5 Editions
Protobuf Editions move language semantics from syntax = "proto2/proto3" toward edition-based feature settings. Field presence can be controlled through edition features. For new systems, align with tooling maturity and organization standards before adopting editions broadly.
Contract lesson:
Presence semantics is part of schema contract. Do not leave it as generator accident.
9. Required Fields
Proto3 removed required. Protobuf best practice generally avoids required for evolvability.
Why required is dangerous:
- adding required field breaks old writers;
- removing required field breaks readers;
- partial data becomes invalid;
- independent deployability suffers.
Model business-required fields at validation layer, not wire format, when possible.
Example:
message CreateCustomerCommand {
string command_id = 1;
string customer_id = 2;
string full_name = 3;
}
Even if full_name is business-required, Protobuf schema may not enforce it. Application validation must.
10. Enum Contract
Enum:
enum CaseStatus {
CASE_STATUS_UNSPECIFIED = 0;
CASE_STATUS_SUBMITTED = 1;
CASE_STATUS_UNDER_REVIEW = 2;
CASE_STATUS_APPROVED = 3;
CASE_STATUS_CLOSED = 4;
}
10.1 First Value Must Be Zero
Proto3 enum first value should be zero because it is default. Use UNSPECIFIED or UNKNOWN.
Bad:
enum CaseStatus {
CASE_STATUS_APPROVED = 0;
CASE_STATUS_REJECTED = 1;
}
If field absent, default becomes APPROVED. Terrible.
Good:
enum CaseStatus {
CASE_STATUS_UNSPECIFIED = 0;
CASE_STATUS_APPROVED = 1;
CASE_STATUS_REJECTED = 2;
}
10.2 Prefix Enum Values
Because enum values share scope in generated languages/descriptors, prefix values.
Good:
enum KycStatus {
KYC_STATUS_UNSPECIFIED = 0;
KYC_STATUS_NOT_STARTED = 1;
KYC_STATUS_PENDING = 2;
KYC_STATUS_VERIFIED = 3;
KYC_STATUS_REJECTED = 4;
}
10.3 Adding Enum Values
Adding enum value can be wire-compatible, but consumer logic may not handle it.
Old consumer:
switch (status) {
case KYC_STATUS_VERIFIED -> approve();
case KYC_STATUS_REJECTED -> reject();
}
New producer emits:
KYC_STATUS_MANUAL_REVIEW
Consumer may default/fail/ignore depending code.
Contract must say whether enum is open/evolvable and how consumers handle unknown values.
10.4 Reserved Enum Values
When removing enum value:
enum KycStatus {
KYC_STATUS_UNSPECIFIED = 0;
KYC_STATUS_NOT_STARTED = 1;
KYC_STATUS_PENDING = 2;
KYC_STATUS_VERIFIED = 3;
reserved 4;
reserved "KYC_STATUS_REJECTED";
}
Do not reuse enum numbers.
11. Unknown Fields
Protobuf binary parsers may preserve unknown fields in some languages/runtimes, but application logic should not depend on unknown fields unless specifically designed.
Forward compatibility often works because old readers ignore unknown fields.
But risks:
- old consumer silently ignores important new field;
- proxy/transcoder may drop unknown fields;
- JSON mapping may not preserve unknowns;
- generated business logic does not know new semantics.
Compatibility is not just parser success.
12. Repeated Fields
repeated string evidence_ids = 5;
Repeated fields default to empty list.
Contract questions:
- Is order meaningful?
- Are duplicates allowed?
- Is max size defined?
- Is empty list different from absent?
- Does producer preserve order?
- Can consumer treat as set?
Document semantics in comments and governance docs.
Example:
// Evidence IDs attached at approval time.
// Order is not significant. Values are unique within the list.
repeated string evidence_ids = 5;
13. Maps
map<string, string> attributes = 10;
Maps are convenient but dangerous.
Use maps for:
- dynamic metadata;
- labels;
- attributes that are not stable schema.
Do not use map as schema escape hatch for important contract fields.
Bad:
map<string, string> data = 1;
This loses type safety and governance.
Contract rules:
- define allowed keys if possible;
- define whether consumers may depend on keys;
- do not put sensitive data into arbitrary maps;
- avoid using maps for versioned domain model.
14. oneof
oneof means only one field in a set can be set.
Example:
message VerificationMethod {
oneof method {
DocumentVerification document = 1;
BiometricVerification biometric = 2;
}
}
Generated Java provides case API:
switch (verification.getMethodCase()) {
case DOCUMENT -> ...
case BIOMETRIC -> ...
case METHOD_NOT_SET -> ...
}
14.1 oneof Use Cases
Good for:
- polymorphic payload;
- mutually exclusive variants;
- explicit command choices;
- avoiding multiple nullable fields.
14.2 oneof Compatibility Risks
Dangerous changes:
- moving existing field into oneof;
- moving field out of oneof;
- deleting oneof field without reserving tag;
- reusing oneof tag;
- adding new variant that old consumers do not handle;
- changing field type inside oneof.
Adding new oneof field may be wire-compatible but source/semantic dangerous.
14.3 Unknown oneof Variant
Old consumer may not know new variant. It may see oneof not set or preserve unknown field depending language/runtime behavior. Business logic must handle unknown/default case.
14.4 oneof vs Separate Event Types
For event contracts, avoid one giant event with oneof for all domain events unless there is strong reason.
Bad:
message Event {
oneof payload {
CaseSubmitted case_submitted = 1;
CaseApproved case_approved = 2;
CustomerRegistered customer_registered = 3;
PaymentCaptured payment_captured = 4;
}
}
Problems:
- one central schema changes for every event;
- all consumers regenerate for unrelated event;
- governance bottleneck;
- event catalog unclear;
- unknown variants handling complex.
Better:
- common envelope;
- separate message type per event;
- topic may carry multiple event types with schema registry.
15. Any
google.protobuf.Any allows embedding arbitrary message types.
import "google/protobuf/any.proto";
message EventEnvelope {
string event_id = 1;
string event_type = 2;
google.protobuf.Any payload = 3;
}
Pros:
- dynamic payload;
- type URL;
- flexible envelope.
Cons:
- weak static governance;
- runtime unpacking complexity;
- schema registry integration needs discipline;
- consumers may not know type;
- harder compatibility analysis;
- can become “object” escape hatch.
Use Any for platform infrastructure only if you have strong registry/type URL governance.
16. Timestamp, Date, Money
16.1 Timestamp
Use google.protobuf.Timestamp for instants:
import "google/protobuf/timestamp.proto";
message CaseApproved {
google.protobuf.Timestamp approved_at = 5;
}
This is better than raw string timestamp for binary Protobuf systems.
16.2 Date
Protobuf has common Google types such as google.type.Date in Google APIs, but availability depends on dependency/tooling. Some organizations define their own date:
message LocalDate {
int32 year = 1;
int32 month = 2;
int32 day = 3;
}
or use ISO date string if interoperability matters:
string birth_date = 4; // ISO-8601 date, yyyy-MM-dd.
Choose and standardize.
16.3 Money
Avoid double.
Option:
message Money {
string currency_code = 1; // ISO 4217
string decimal_value = 2; // decimal string, e.g. "1000.00"
}
or units/nanos style:
message Money {
string currency_code = 1;
int64 units = 2;
int32 nanos = 3;
}
Pick one organization-wide.
Questions:
- precision;
- scale;
- rounding;
- negative values;
- currency validation;
- Java
BigDecimalmapping.
17. Package and Java Options
Proto package:
package acme.case.events.v1;
Java options:
option java_multiple_files = true;
option java_package = "com.acme.case.events.v1";
option java_outer_classname = "CaseEventsProto";
17.1 Package Is Contract
Changing proto package changes type identity and generated code.
17.2 Java Package vs Proto Package
You can separate proto package and Java package, but do not change casually.
Governance:
- package naming convention;
- versioning convention;
- generated Java package stability;
- artifact publishing strategy.
18. Message Versioning Strategy
Protobuf often avoids message name versioning if changes are compatible.
Example:
message CaseApproved {
string event_id = 1;
string case_id = 2;
int64 case_version = 3;
}
Add field:
message CaseApproved {
string event_id = 1;
string case_id = 2;
int64 case_version = 3;
string reason_code = 4;
}
No new message name needed if compatible.
When incompatible model needed:
message CaseApprovedV2 {
...
}
or new package:
package acme.case.events.v2;
But this creates migration burden. Prefer compatible evolution when possible.
19. Safe, Dangerous, Breaking Changes
19.1 Usually Wire-Compatible
| Change | Notes |
|---|---|
| Add new field with new number | Old readers ignore |
| Rename field keeping same number | Wire-compatible, source-breaking |
| Add enum value | Parser may handle, business logic may not |
| Add optional field | Wire-compatible |
| Add repeated field | Wire-compatible |
| Add message field | Wire-compatible |
| Deprecate field | Does not change wire |
19.2 Dangerous
| Change | Why |
|---|---|
| Rename field | generated Java source break |
| Change default business meaning | semantic break |
| Add enum value | consumer switch may fail |
| Add oneof variant | old logic may not handle |
| Change JSON name | JSON transcoding break |
| Change package/java_package | generated code break |
| Change field presence | application behavior changes |
| Move field into oneof | compatibility hazard |
| Change from scalar to wrapper/optional | source/wire/semantic concerns |
19.3 Breaking
| Change | Why |
|---|---|
| Reuse field number | catastrophic semantic corruption |
| Change field type to incompatible wire type | parsing break |
| Delete field without reserve and later reuse | future corruption |
| Change repeated to scalar or scalar to repeated | unsafe |
| Change map to repeated message or vice versa | compatibility risk |
| Remove enum value and reuse number | corruption |
| Change service method request/response type incompatibly | gRPC break |
| Remove gRPC method | client break |
20. Deprecation
Protobuf supports deprecated option:
message Customer {
string customer_id = 1;
string status = 2 [deprecated = true];
string lifecycle_status = 3;
}
Generated Java may mark accessor as deprecated.
Deprecation policy:
- do not stop populating immediately;
- document replacement;
- track consumers;
- reserve when removed;
- avoid reusing tags/names.
21. gRPC Service Contracts
Protobuf messages define data contract. gRPC services define interaction contract.
Example:
service CustomerService {
rpc GetCustomer(GetCustomerRequest) returns (Customer);
rpc CreateCustomer(CreateCustomerRequest) returns (Customer);
rpc StreamCustomerEvents(StreamCustomerEventsRequest) returns (stream CustomerEvent);
}
Service contract includes:
- method name;
- request message;
- response message;
- streaming type;
- error status model;
- deadlines/timeouts;
- metadata headers;
- authentication;
- idempotency;
- pagination/page tokens;
- compatibility and versioning.
21.1 Unary RPC
rpc GetCustomer(GetCustomerRequest) returns (Customer);
Similar to request/response API.
21.2 Server Streaming
rpc StreamCustomerEvents(StreamCustomerEventsRequest)
returns (stream CustomerEvent);
Contract must define:
- ordering;
- reconnect behavior;
- resume token;
- heartbeat;
- backpressure;
- stream termination;
- error recovery.
21.3 Client Streaming / Bidirectional Streaming
Higher complexity. Define flow control, partial failure, message ordering, and lifecycle carefully.
22. gRPC Error Contract
Do not rely only on generic gRPC status codes.
gRPC status examples:
| Status | Meaning |
|---|---|
INVALID_ARGUMENT | request invalid |
NOT_FOUND | resource not found |
FAILED_PRECONDITION | state/precondition not met |
ABORTED | concurrency conflict |
PERMISSION_DENIED | authz failure |
UNAUTHENTICATED | authn failure |
RESOURCE_EXHAUSTED | rate/quota |
UNAVAILABLE | transient unavailable |
INTERNAL | internal error |
For rich errors, use structured error details where supported.
Contract should define:
- status code;
- stable application error code;
- retryability;
- field violations;
- correlation/request ID metadata;
- error detail messages.
Java consumers should not parse human error text.
23. gRPC Metadata
Metadata is like headers.
Contract metadata:
- authorization;
- request ID;
- correlation ID;
- tenant ID;
- idempotency key;
- deadline;
- locale;
- trace context.
Example:
x-correlation-id: corr_123
x-tenant-id: tenant_123
idempotency-key: idem_123
Document metadata just like HTTP headers.
24. Java Generated Code
Protobuf Java generated code exposes:
- immutable message classes;
- builders;
- getters;
hasX()for fields with presence;- enum types;
getXValue()for raw enum number sometimes;- parsers/serializers;
- unknown fields APIs;
- service stubs for gRPC.
24.1 Generated Code Is Public API If Exposed
If consumer imports generated message classes, .proto changes can be source-breaking even when wire-compatible.
Example field rename:
string status = 2;
to:
string lifecycle_status = 2;
Wire-compatible, but Java method changes:
getStatus()
to:
getLifecycleStatus()
24.2 SDK Wrapper Option
For strategic APIs, wrap generated stubs/messages:
public final class CustomerGrpcClient {
private final CustomerServiceGrpc.CustomerServiceBlockingStub stub;
public Customer getCustomer(CustomerId id) {
GetCustomerRequest request = GetCustomerRequest.newBuilder()
.setCustomerId(id.value())
.build();
return mapper.toDomain(stub.getCustomer(request));
}
}
This isolates generated churn.
25. Protobuf JSON Mapping
Protobuf has JSON mapping rules. If messages are used over HTTP/JSON transcoding, JSON contract matters too.
Risks:
- field names become lowerCamelCase by default in JSON;
- json_name option changes JSON field;
- default values may be omitted;
- enum JSON names are strings by default;
- unknown fields in JSON may be rejected/ignored depending parser;
- bytes base64 encoding;
- timestamp JSON format.
If using gRPC-Gateway or transcoding, treat JSON mapping as separate API contract.
26. Event Envelope with Protobuf
Common envelope:
syntax = "proto3";
package acme.events.v1;
option java_multiple_files = true;
option java_package = "com.acme.events.v1";
import "google/protobuf/timestamp.proto";
message EventMetadata {
string event_id = 1;
string event_type = 2;
string event_version = 3;
string source = 4;
string subject = 5;
string aggregate_type = 6;
string aggregate_id = 7;
int64 aggregate_version = 8;
google.protobuf.Timestamp occurred_at = 9;
google.protobuf.Timestamp published_at = 10;
string correlation_id = 11;
string causation_id = 12;
string trace_id = 13;
string schema_ref = 14;
string tenant_id = 15;
string jurisdiction = 16;
string data_classification = 17;
bool pii = 18;
}
CaseApproved:
syntax = "proto3";
package acme.case.events.v1;
option java_multiple_files = true;
option java_package = "com.acme.case.events.v1";
import "acme/events/v1/event_metadata.proto";
import "google/protobuf/timestamp.proto";
message CaseApproved {
acme.events.v1.EventMetadata metadata = 1;
CaseApprovedPayload payload = 2;
}
message CaseApprovedPayload {
string case_id = 1;
int64 case_version = 2;
string approved_by = 3;
google.protobuf.Timestamp approved_at = 4;
string reason_code = 5;
}
27. Protobuf in Kafka/Event Streaming
Protobuf can be used for Kafka event payloads with schema registry.
Contract dimensions remain:
- topic;
- key;
- event type;
- Protobuf message type;
- schema subject;
- compatibility mode;
- generated code version;
- enum/presence evolution;
- replay.
Protobuf schema compatibility does not protect Kafka key/retention/order changes.
28. Protobuf vs Avro Decision Matrix
| Dimension | Avro | Protobuf |
|---|---|---|
| Evolution model | reader/writer schema resolution | field number/wire compatibility |
| Schema format | JSON schema files | .proto IDL |
| Field identity | name-based resolution | number-based wire identity |
| Defaults | reader resolution defaults | language defaults/presence |
| Java generation | SpecificRecord/GenericRecord | generated immutable classes/builders |
| Human readability | Avro JSON schema verbose | proto IDL concise |
| Kafka registry usage | very common | common |
| gRPC | not native | native |
| Open enum handling | can be tricky | unknown enum handling possible but app logic needed |
| Dynamic processing | GenericRecord strong | Any/dynamic descriptors possible |
| Best fit | data streams, analytics, schema evolution with registry | APIs/RPC, multi-language contracts, compact messages |
Choose based on:
- ecosystem;
- gRPC need;
- schema registry maturity;
- data platform needs;
- Java/client generated ergonomics;
- compatibility model preference;
- multi-language requirements.
29. Protobuf Contract Testing
29.1 Descriptor Diff
CI should detect:
- field number reuse;
- field type change;
- removed field not reserved;
- enum number reuse;
- package changes;
- service method removal;
- request/response type changes;
- presence changes;
- oneof changes.
Tools like Buf are commonly used in Protobuf ecosystems for breaking change detection and linting.
29.2 Golden Binary Test
Old binary data should parse with new code.
@Test
void newReaderParsesOldCustomerRegisteredBinary() throws Exception {
byte[] bytes = fixture("customer-registered-v1.bin");
CustomerRegistered event = CustomerRegistered.parseFrom(bytes);
assertThat(event.getCustomerId()).isEqualTo("cus_123");
}
29.3 New Data / Old Reader Test
If forward compatibility required, compile old reader in compatibility test or use descriptor-based tests.
29.4 Unknown Enum Test
@Test
void unknownEnumValueDoesNotCrashConsumer() {
// Build bytes containing new enum numeric value unknown to old generated code.
// Verify consumer routes to UNKNOWN/default handling.
}
29.5 gRPC Contract Test
Test:
- status code;
- metadata;
- deadlines;
- rich error details;
- streaming behavior;
- backward-compatible request/response changes.
30. Protobuf Review Checklist
30.1 Field Numbers
- Are new field numbers unique?
- Are removed numbers reserved?
- Are removed names reserved?
- Is field number reused? Reject.
- Are low numbers used for common fields?
30.2 Presence
- Does absence vs default matter?
- Should scalar field be
optional? - Is wrapper type needed?
- Is PATCH/update modeled safely?
- Are business-required fields validated outside wire schema?
30.3 Types
- Is money not double?
- Are timestamps using proper type?
- Are IDs strings?
- Are maps not used as escape hatch?
- Are repeated fields documented for order/duplicates?
30.4 Enums
- Is zero value unspecified?
- Are enum values prefixed?
- Are removed enum numbers/names reserved?
- Can consumers handle new enum values?
- Is enum truly closed?
30.5 oneof
- Is oneof needed?
- Are variants stable?
- Are new variants handled by old consumers?
- Are removed tags reserved?
- Is oneof being used as giant generic event wrapper?
30.6 gRPC
- Are method names stable?
- Are request/response types stable?
- Are error statuses documented?
- Is metadata documented?
- Are streaming semantics clear?
30.7 Java
- Is generated package stable?
- Are generated classes exposed publicly?
- Would rename break consumers?
- Is wrapper SDK needed?
- Is generated code versioned?
31. Common Protobuf Anti-Patterns
31.1 Reusing Field Number
Most dangerous.
31.2 Removing Field Without Reserve
Future engineer may reuse it.
31.3 Zero Enum Value Has Business Meaning
Absent field becomes meaningful state.
31.4 Parsing Error Message
gRPC error text as logic.
31.5 Map as Domain Model
map<string,string> attributes = 1 for everything.
31.6 Ignoring Presence
Cannot distinguish absent from default.
31.7 Renaming Field in Public Generated API
Wire okay, Java source broken.
31.8 oneof for All Events
Central schema bottleneck.
31.9 Using double for Money
Precision bugs.
31.10 Changing Package Casually
Generated code/type identity break.
31.11 Assuming Protobuf Compatibility Covers JSON
Transcoding has separate contract concerns.
31.12 Assuming gRPC Status Is Enough
Need stable application error details.
32. Practice Lab
Lab 1 — Safe Field Addition
Given:
message CustomerRegistered {
string customer_id = 1;
}
Add registration channel and timestamp safely.
Lab 2 — Remove Field
Given:
message Customer {
string customer_id = 1;
string email_address = 2;
}
Remove email_address safely.
Lab 3 — Presence
Design update profile request where:
- absent display name = no change;
- display name empty string = invalid;
- middle name absent = no change;
- middle name present empty = clear.
Use proto3 optional/message wrappers/oneof and explain trade-off.
Lab 4 — Enum Evolution
Existing enum:
enum RiskBand {
RISK_BAND_UNSPECIFIED = 0;
RISK_BAND_LOW = 1;
RISK_BAND_MEDIUM = 2;
RISK_BAND_HIGH = 3;
}
Add CRITICAL and define consumer handling.
Lab 5 — oneof Review
Review:
message Event {
oneof payload {
CaseApproved case_approved = 1;
CustomerRegistered customer_registered = 2;
PaymentCaptured payment_captured = 3;
}
}
Identify governance and compatibility risks.
Lab 6 — gRPC Error Contract
Design error contract for:
rpc ApproveCase(ApproveCaseRequest) returns (Case);
Errors:
- missing reason;
- case not found;
- case in wrong state;
- permission denied;
- dependency unavailable.
33. Senior Engineer Heuristics
- In Protobuf, field number is more important than field name.
- Never reuse field numbers.
- Reserve removed numbers and names.
- Field rename can be wire-compatible but Java-source-breaking.
- Proto3 defaults can hide absence.
- Use explicit presence when absence matters.
- Zero enum value should mean unspecified/unknown.
- Adding enum values is not always business-compatible.
- oneof is powerful but compatibility-sensitive.
- Do not use Any without registry governance.
- gRPC service method is contract, not just generated stub.
- gRPC errors need stable machine-readable details.
- Generated code exposure turns schema changes into SDK changes.
- Protobuf compatibility and JSON transcoding compatibility are different.
- Schema diff must detect tag reuse and package/service breakage.
34. Summary
Protobuf contract engineering centers on field numbers, presence, reserved fields, enum evolution, oneof compatibility, and generated code behavior. It is excellent for compact multi-language contracts and gRPC services, but compatibility discipline is non-negotiable.
Main takeaways:
- field numbers are wire contract anchors;
- never reuse numbers;
- reserve removed numbers and names;
- proto3 default values can hide absence;
- use optional/presence-aware modeling where absence matters;
- enums need zero unspecified value and unknown handling strategy;
- oneof changes require careful review;
- gRPC service contracts include methods, metadata, errors, streaming, and deadlines;
- Java generated code may break even when wire compatibility holds;
- Protobuf schema compatibility does not cover Kafka topic/key/retention semantics.
Part berikutnya membahas JSON Schema contract engineering: constraints, composition, dialects, $id, $ref, $defs, validation boundaries, and alignment with OpenAPI.
You just completed lesson 18 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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.