Build CoreOrdered learning track

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.

18 min read3510 words
PrevNext
Lesson 1832 lesson track0718 Build Core
#java#protobuf#grpc#schema-evolution+3 more

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:

  1. menjelaskan kenapa field number adalah compatibility anchor;
  2. membedakan field name rename vs field number reuse;
  3. memakai reserved untuk mencegah tag/name reuse;
  4. memahami proto3 default values dan presence;
  5. memahami proto3 optional dan Protobuf Editions presence model;
  6. mendesain enum yang evolvable;
  7. menilai oneof compatibility;
  8. membedakan Protobuf message contract dan gRPC service contract;
  9. memahami Java generated code implications;
  10. membangun review checklist untuk .proto changes;
  11. 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:

ElementRole
syntax / editionlanguage mode
packageProtobuf namespace
java_packageJava generated package
messagerecord/object contract
field namesource/generated code name
field numberwire tag
field typewire/source type
optionscodegen/language/tooling behavior

3. Field Numbers

Rules:

  1. field numbers must be unique within a message;
  2. field numbers should never be reused for different meaning;
  3. low numbers are more compact on wire;
  4. field numbers 1-15 use fewer bytes than higher numbers;
  5. reserved range 19000-19999 is for Protobuf implementation;
  6. 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?

  1. avoids reintroducing old generated accessor names with different meaning;
  2. prevents JSON/text-format confusion;
  3. documents history;
  4. 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:

TypeDefault
stringempty string
bytesempty bytes
boolfalse
numericzero
enumfirst enum value, usually zero
repeatedempty list
messagenot 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:

  1. PATCH/update semantics;
  2. optional business fields;
  3. nullable-like modeling;
  4. compatibility;
  5. 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:

  1. adding required field breaks old writers;
  2. removing required field breaks readers;
  3. partial data becomes invalid;
  4. 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:

  1. old consumer silently ignores important new field;
  2. proxy/transcoder may drop unknown fields;
  3. JSON mapping may not preserve unknowns;
  4. 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:

  1. Is order meaningful?
  2. Are duplicates allowed?
  3. Is max size defined?
  4. Is empty list different from absent?
  5. Does producer preserve order?
  6. 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:

  1. dynamic metadata;
  2. labels;
  3. 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:

  1. define allowed keys if possible;
  2. define whether consumers may depend on keys;
  3. do not put sensitive data into arbitrary maps;
  4. 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:

  1. polymorphic payload;
  2. mutually exclusive variants;
  3. explicit command choices;
  4. avoiding multiple nullable fields.

14.2 oneof Compatibility Risks

Dangerous changes:

  1. moving existing field into oneof;
  2. moving field out of oneof;
  3. deleting oneof field without reserving tag;
  4. reusing oneof tag;
  5. adding new variant that old consumers do not handle;
  6. 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:

  1. one central schema changes for every event;
  2. all consumers regenerate for unrelated event;
  3. governance bottleneck;
  4. event catalog unclear;
  5. 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:

  1. dynamic payload;
  2. type URL;
  3. flexible envelope.

Cons:

  1. weak static governance;
  2. runtime unpacking complexity;
  3. schema registry integration needs discipline;
  4. consumers may not know type;
  5. harder compatibility analysis;
  6. 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:

  1. precision;
  2. scale;
  3. rounding;
  4. negative values;
  5. currency validation;
  6. Java BigDecimal mapping.

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:

  1. package naming convention;
  2. versioning convention;
  3. generated Java package stability;
  4. 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

ChangeNotes
Add new field with new numberOld readers ignore
Rename field keeping same numberWire-compatible, source-breaking
Add enum valueParser may handle, business logic may not
Add optional fieldWire-compatible
Add repeated fieldWire-compatible
Add message fieldWire-compatible
Deprecate fieldDoes not change wire

19.2 Dangerous

ChangeWhy
Rename fieldgenerated Java source break
Change default business meaningsemantic break
Add enum valueconsumer switch may fail
Add oneof variantold logic may not handle
Change JSON nameJSON transcoding break
Change package/java_packagegenerated code break
Change field presenceapplication behavior changes
Move field into oneofcompatibility hazard
Change from scalar to wrapper/optionalsource/wire/semantic concerns

19.3 Breaking

ChangeWhy
Reuse field numbercatastrophic semantic corruption
Change field type to incompatible wire typeparsing break
Delete field without reserve and later reusefuture corruption
Change repeated to scalar or scalar to repeatedunsafe
Change map to repeated message or vice versacompatibility risk
Remove enum value and reuse numbercorruption
Change service method request/response type incompatiblygRPC break
Remove gRPC methodclient 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:

  1. do not stop populating immediately;
  2. document replacement;
  3. track consumers;
  4. reserve when removed;
  5. 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:

  1. method name;
  2. request message;
  3. response message;
  4. streaming type;
  5. error status model;
  6. deadlines/timeouts;
  7. metadata headers;
  8. authentication;
  9. idempotency;
  10. pagination/page tokens;
  11. 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:

  1. ordering;
  2. reconnect behavior;
  3. resume token;
  4. heartbeat;
  5. backpressure;
  6. stream termination;
  7. 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:

StatusMeaning
INVALID_ARGUMENTrequest invalid
NOT_FOUNDresource not found
FAILED_PRECONDITIONstate/precondition not met
ABORTEDconcurrency conflict
PERMISSION_DENIEDauthz failure
UNAUTHENTICATEDauthn failure
RESOURCE_EXHAUSTEDrate/quota
UNAVAILABLEtransient unavailable
INTERNALinternal error

For rich errors, use structured error details where supported.

Contract should define:

  1. status code;
  2. stable application error code;
  3. retryability;
  4. field violations;
  5. correlation/request ID metadata;
  6. error detail messages.

Java consumers should not parse human error text.


23. gRPC Metadata

Metadata is like headers.

Contract metadata:

  1. authorization;
  2. request ID;
  3. correlation ID;
  4. tenant ID;
  5. idempotency key;
  6. deadline;
  7. locale;
  8. 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:

  1. immutable message classes;
  2. builders;
  3. getters;
  4. hasX() for fields with presence;
  5. enum types;
  6. getXValue() for raw enum number sometimes;
  7. parsers/serializers;
  8. unknown fields APIs;
  9. 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:

  1. field names become lowerCamelCase by default in JSON;
  2. json_name option changes JSON field;
  3. default values may be omitted;
  4. enum JSON names are strings by default;
  5. unknown fields in JSON may be rejected/ignored depending parser;
  6. bytes base64 encoding;
  7. 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:

  1. topic;
  2. key;
  3. event type;
  4. Protobuf message type;
  5. schema subject;
  6. compatibility mode;
  7. generated code version;
  8. enum/presence evolution;
  9. replay.

Protobuf schema compatibility does not protect Kafka key/retention/order changes.


28. Protobuf vs Avro Decision Matrix

DimensionAvroProtobuf
Evolution modelreader/writer schema resolutionfield number/wire compatibility
Schema formatJSON schema files.proto IDL
Field identityname-based resolutionnumber-based wire identity
Defaultsreader resolution defaultslanguage defaults/presence
Java generationSpecificRecord/GenericRecordgenerated immutable classes/builders
Human readabilityAvro JSON schema verboseproto IDL concise
Kafka registry usagevery commoncommon
gRPCnot nativenative
Open enum handlingcan be trickyunknown enum handling possible but app logic needed
Dynamic processingGenericRecord strongAny/dynamic descriptors possible
Best fitdata streams, analytics, schema evolution with registryAPIs/RPC, multi-language contracts, compact messages

Choose based on:

  1. ecosystem;
  2. gRPC need;
  3. schema registry maturity;
  4. data platform needs;
  5. Java/client generated ergonomics;
  6. compatibility model preference;
  7. multi-language requirements.

29. Protobuf Contract Testing

29.1 Descriptor Diff

CI should detect:

  1. field number reuse;
  2. field type change;
  3. removed field not reserved;
  4. enum number reuse;
  5. package changes;
  6. service method removal;
  7. request/response type changes;
  8. presence changes;
  9. 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:

  1. status code;
  2. metadata;
  3. deadlines;
  4. rich error details;
  5. streaming behavior;
  6. 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:

  1. missing reason;
  2. case not found;
  3. case in wrong state;
  4. permission denied;
  5. dependency unavailable.

33. Senior Engineer Heuristics

  1. In Protobuf, field number is more important than field name.
  2. Never reuse field numbers.
  3. Reserve removed numbers and names.
  4. Field rename can be wire-compatible but Java-source-breaking.
  5. Proto3 defaults can hide absence.
  6. Use explicit presence when absence matters.
  7. Zero enum value should mean unspecified/unknown.
  8. Adding enum values is not always business-compatible.
  9. oneof is powerful but compatibility-sensitive.
  10. Do not use Any without registry governance.
  11. gRPC service method is contract, not just generated stub.
  12. gRPC errors need stable machine-readable details.
  13. Generated code exposure turns schema changes into SDK changes.
  14. Protobuf compatibility and JSON transcoding compatibility are different.
  15. 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:

  1. field numbers are wire contract anchors;
  2. never reuse numbers;
  3. reserve removed numbers and names;
  4. proto3 default values can hide absence;
  5. use optional/presence-aware modeling where absence matters;
  6. enums need zero unspecified value and unknown handling strategy;
  7. oneof changes require careful review;
  8. gRPC service contracts include methods, metadata, errors, streaming, and deadlines;
  9. Java generated code may break even when wire compatibility holds;
  10. 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.

Lesson Recap

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.