Version-Agnostic Protobuf in Java
How We Stopped Schema Versions from Leaking into Business Logic
Version-Agnostic Protobuf in Java
How We Stopped Schema Versions from Leaking into Business Logic
The Problem Nobody Talks About
If you maintain a Java backend that has been running in production for years, you’ve probably seen protobuf schema versions slowly leak into business logic.
Protocol Buffers are fast, compact, language-agnostic, and generally a solid choice for defining service contracts. Everything works fine — until your schema needs to evolve.
At first, changes are small.
An int32 suddenly isn’t enough.
A magic number cries out to be replaced with a proper enum.
A flat structure grows into a nested message.
Individually, these changes are reasonable. Collectively, they lead to a problem that almost every long-lived protobuf-based system eventually hits:
multiple schema versions must coexist in production.
Old clients still send data. Historical messages are stored in Kafka or S3. Backward compatibility is not optional — it’s reality.
message Order {
int32 total_amount = 1;
int32 payment_type = 2;
}
message Order {
int64 total_amount = 1;
PaymentType payment_type = 2;
CustomerInfo customer = 3;
}
From Java’s point of view, these are completely unrelated classes. No shared interface. No polymorphism.
This is not a bug. This is protobuf working exactly as designed.
TL;DR
Protocol Buffers do not generate interfaces. When schemas evolve, Java code must deal with multiple incompatible classes representing the same concept.
The usual outcomes are:
- version-based branching
- duplicated business logic
- manual adapters
- reflection hacks
We built a Maven plugin that generates version-agnostic Java interfaces on top of multiple proto versions.
The Ugly Solutions
- Version-Based If-Else Branching
if (version == 1) {
processV1(order);
} else if (version == 2) {
processV2(order);
}
This doesn’t scale.
Every new version adds:
- another branch
- another copy of the same logic
- another set of tests
Bug fixes and features must be implemented N times. Versions slowly diverge — not by design, but by accident.
2. Manual Adapter Classes
public interface Order {
long getTotalAmount();
}
Each version gets its own adapter.
Architecturally clean — but expensive.
Schemas change. Adapters must be updated manually. Nothing enforces consistency.
At scale, this becomes a maintenance tax.
3. Reflection-Based Access
Method m = proto.getClass().getMethod("getTotalAmount");
Reflection removes:
- compile-time safety
- IDE support
- refactoring guarantees
- It works — until it doesn’t
This is not abstraction. It’s surrender.
Why Can’t Protobuf Just Generate Interfaces?
This is a fair question. And no — it’s not an oversight.
Performance Comes First
Protobuf is used at massive scale. Virtual dispatch and abstraction layers matter there.
Protobuf optimizes for serialization, not application architecture.
Cross-Language Semantics Don’t Translate
- Java → interfaces
- Rust → traits with explicit implementations
- C++ → abstract classes
- Go → implicit interfaces
- Python → duck typing
Trying to unify this inevitably leads to leaky abstractions.
What looks like a natural interface in Java becomes a language-specific illusion elsewhere.
These Are Valid Trade-Offs
Protobuf optimizes for serialization. Applications optimize for maintainability.
Those goals don’t always align.
Our Solution: proto-wrapper-plugin
If protobuf won’t give us a shared abstraction, we generate one ourselves.
The plugin:
- analyzes multiple schema versions
- merges them into a unified model
- generates version-agnostic Java interfaces
- and version-specific wrappers
Your application code depends only on interfaces.
What Using It Looks Like
VersionContext ctx = VersionContext.forVersion(version);
Order order = ctx.parseOrderFromBytes(payload)
process(order);
No branching. No duplication. No reflection.
What Changes in Practice
- One implementation of business logic
- Compile-time safety across versions
- Binary compatibility preserved
- Version handling pushed to the edges
How It Works
At its core, the plugin automates the work you would otherwise do by hand — on every schema change.
- Read multiple proto versions
- Analyze descriptors (not text files)
- Merge schemas into a unified model
- Generate interfaces, wrappers, and factories

Generated code is intentionally boring. That’s a feature.
Smart Type Conflict Resolution
Generating interfaces is the easy part. The hard part is answering a much more interesting question:
What should a version-agnostic API look like when schemas actually change?
Real protobuf schemas don’t evolve politely. Fields change type. Enums replace magic numbers. Simple values grow into structured messages.
INT ↔ ENUM: From Magic Numbers to Meaning
One of the most common evolutions looks like this:
// v1
message Order {
int32 payment_type = 1; // 0=CASH, 1=CARD
}
// v2
message Order {
PaymentType payment_type = 1;
}
enum PaymentType {
CASH = 0;
CARD = 1;
CRYPTO = 2;
}
Older schemas encode meaning implicitly. Newer ones make it explicit.
The plugin generates a unified enum and exposes both access patterns:
public interface Order {
/** Raw numeric value — works for all versions */
int getPaymentType();
/** Type-safe access — available where possible */
PaymentTypeEnum getPaymentTypeEnum();
}
This gives you flexibility:
- legacy code can keep using raw values,
- business logic can switch to enums incrementally.
No forced migrations. No breaking changes.
Numeric Widening: int32 → int64
Another classic problem: numbers grow.
// v1
int32 total_amount = 1;
// v2
int64 total_amount = 1;
The unified API always exposes the wider type:
public interface Order {
long getTotalAmount();
}
Implementations handle conversion automatically:
- v1 widens
int → long - v2 passes the value through unchanged
When builders are enabled, the plugin also adds range validation:
order.toBuilder()
.setTotalAmount(5_000_000_000L)
.build(); // fails for v1, safe for v2
Errors surface early — during development — not after corrupted data hits production.
String ↔ Bytes: Encoding Changes
Sometimes schemas change how data is represented:
// v1
string checksum = 1;
// v2
bytes checksum = 1;
The plugin exposes both interpretations:
public interface Report {
String getChecksum();
byte[] getChecksumBytes();
}
Conversions are handled automatically using UTF-8:
- bytes → decoded string
- string → encoded bytes
Your code chooses the representation it needs.
Primitive ↔ Message: When Simple Becomes Structured
This is a harder evolution:
// v1
int32 shipping_cost = 1;
// v2
Money shipping_cost = 1;
Here, there is no perfect abstraction.
So the plugin does the honest thing:
public interface Order {
/** Primitive access (0 if not available) */
int getShippingCost();
/** Structured access (null if not available) */
Money getShippingCostMessage();
}
Nothing is hidden. Nothing is guessed.
If a version doesn’t support a representation, you see it immediately.
Fail Fast Beats Silent Corruption
Not every conflict can be resolved safely.
If the plugin encounters:
- incompatible field numbers
- irreconcilable type changes
- ambiguous mappings
it fails at generation time.
That’s intentional.
A broken build is annoying. Silent data corruption is far worse.
Why This Matters in Practice
These rules mean that schema evolution becomes:
- predictable — no surprises
- visible — differences are explicit
- safe — invalid states are hard to represent
You still evolve schemas freely. You just stop paying for that evolution everywhere else in your codebase.
Real-World Example: Data in Production
This plugin wasn’t born out of academic curiosity. It came from a very concrete production problem.
We maintain a fiscal data processing system that receives transactions from cash registers deployed in the field. Communication happens via protobuf. Over the years, the protocol evolved — slowly, inevitably, and not always cleanly.
At the same time, old devices never disappear overnight.
Firmware updates roll out gradually. Some terminals lag behind. Others are constrained by hardware or certification requirements.
As a result, multiple protocol versions are active in production at the same time.
The Reality of Versions
At one point, we had to support:
- v1 — the original protocol, mostly primitive fields
- v2 — new structures
- v3— enums instead of magic numbers, more structure
- v4 — reorganized messages, new payment types, extended reports
- v5— coming soon (in testing)
All five versions were or are valid. All five were or are actively used.
And all five described the same business concepts — just differently.
Before: Version-Centric Code
Before the plugin, the codebase looked roughly like this:
TransactionProcessorV1.java (~1200 lines)
TransactionProcessorV2.java (~1400 lines)
TransactionProcessorV3.java (~1500 lines)
...
The differences were mostly mechanical:
- field access
- enum conversions
- slightly different message shapes
The business logic itself was almost identical.
That duplication had a cost.
- A bug fix had to be applied three times.
- A new validation rule had to be implemented three times.
- Tests had to be written and maintained three times.
Worse, changes didn’t always propagate evenly. Versions slowly diverged — not because they should, but because humans are fallible.
After: Version-Agnostic Core
After introducing the plugin, the structure collapsed into a single implementation:
TransactionProcessor.java (~800 lines)
Version handling moved to the edges:
- parsing
- wrapping
- serialization
The core logic became version-agnostic.
public TransactionResult process(byte[] payload, int protocolVersion) {
VersionContext ctx = VersionContext.forVersion(protocolVersion);
Request request = ctx.parseRequestFromBytes(payload);
validate(request);
Transaction tx = createTransaction(request);
Response response = execute(tx);
return new TransactionResult(response.toBytes());
}
Inside validate, createTransaction, and execute, there are no version checks.
Those methods operate on interfaces: Request, Ticket, Item, Payment.
Versions simply don’t exist there anymore.
The Real Win: Cognitive Load
The biggest improvement wasn’t fewer lines of code — it was less mental overhead.
Developers no longer needed to remember:
- which version encodes what
- where enums became ints
- which fields exist only in newer protocols
When writing business logic, they think in terms of:
- domain concepts
- invariants
- rules
Schema versions became an infrastructure concern, not an application one.
This Is the Use Case It Was Built For
If your system:
- has long-lived protobuf schemas
- must support multiple versions simultaneously
- cannot force instant client upgrades
then this problem is not hypothetical.
You either manage it explicitly — or it manages you.
This plugin simply made the implicit pain explicit — and automated it away.
Limitations: An Honest Assessment
No tool solves every problem. This one is no exception.
The goal of proto-wrapper-plugin is not to cover every corner of the protobuf feature set, but to handle the common, painful cases of schema evolution in long-lived systems.
That means there are limitations — and it’s better to be explicit about them.
What the Plugin Does Not Handle Well

For most production schemas, these are not blockers. Messages, enums, nested types, and repeated fields — the bread and butter of protobuf — are fully supported.
Why These Gaps Exist
Each limitation is a deliberate trade-off, not an oversight.
oneofintroduces semantic ambiguity that is hard to express cleanly in a version-agnostic interface- Extensions are a proto2 feature with limited adoption in modern systems
- Well-known types often require domain-specific handling anyway
Rather than guessing or generating unsafe abstractions, the plugin chooses to be conservative.
When in doubt, it prefers:
- explicit APIs
- predictable behavior
- and failing fast over silent misinterpretation
Practical Workarounds
In practice, these limitations are manageable.
oneoffields can usually be handled explicitly at the application boundary.mapfields behave like regular collections in most business logic.- Well-known types can be wrapped or adapted manually where needed.
The important point is that the hard 90% of schema evolution is automated, while edge cases remain visible and controllable.
A Non-Goal
One thing the plugin explicitly does not try to do:
It does not hide schema evolution.
If a field changes meaning, disappears, or becomes fundamentally incompatible, you should see that in your API.
The plugin helps you manage evolution — it doesn’t pretend it doesn’t exist.
Conclusion
Protocol Buffers are very good at what they were designed to do.
They provide:
- efficient binary serialization
- clear, language-agnostic schemas
- predictable wire compatibility
The design decisions behind protobuf — concrete final classes, no interfaces, minimal abstraction — make sense at scale and across languages.
But those same decisions leave Java applications exposed when schemas evolve over time.
As soon as multiple protocol versions must coexist, teams are forced into:
- branching logic
- duplicated business code
- manual adapters
- or brittle runtime tricks
None of these are inherently wrong. They’re simply compensations for a missing abstraction.
proto-wrapper-plugin fills that gap — without changing protobuf itself.
It builds a thin, generated layer on top:
- interfaces that represent domain concepts
- version-specific wrappers that preserve binary compatibility
- and a clear boundary between protocol handling and business logic
Schema evolution remains real and visible. It just stops leaking into every corner of your codebase.
This approach won’t be necessary for every project.
If you control all clients, upgrade aggressively, or rarely change schemas — protobuf’s default codegen is often enough.
But if you operate a long-lived system where:
- old data must still be processed
- old clients cannot be dropped
- and business logic should not care about protocol versions
then version-agnostic APIs stop being a convenience and become a necessity.
That’s the problem this plugin was built to solve.
If this resonates with your own experience, feel free to explore the project, try it on a real schema, or adapt the idea to your own tooling.
Schema evolution isn’t going away. But it doesn’t have to dominate your architecture either.
Resources
-
🔗 GitHub Repository Source code, issues, and roadmap
-
📘 Cookbook Practical examples and common patterns
-
📚 API Documentation Generated interfaces and contracts
-
📄 Known Issues Limitations, edge cases, and workarounds
-
🧪 Example Project Minimal Maven setup demonstrating real usage
Full configuration examples, extended code samples, and edge cases are documented in the GitHub repository.
메타데이터
- post_id
- c433e73a923e
- slug
- version-agnostic-protobuf-in-java-c433e73a923e
- url
- https://medium.com/@alnovis/version-agnostic-protobuf-in-java-c433e73a923e
- canonical_url
- https://medium.com/@alnovis/version-agnostic-protobuf-in-java-c433e73a923e
- author_url
- https://medium.com/@alnovis
- status
- ok
- fetched_at
- 2026-06-20 20:29:01