← Back to list

Version-Agnostic Protobuf in Java

How We Stopped Schema Versions from Leaking into Business Logic

Alexander Novikov · 2025-12-23 01:25 · 0 claps · 8.7 min read
#protocol-buffers #software-architecture #api-design #code-generation #java
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

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

  1. 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.

  1. Read multiple proto versions
  2. Analyze descriptors (not text files)
  3. Merge schemas into a unified model
  4. 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.

  • oneof introduces 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.

  • oneof fields can usually be handled explicitly at the application boundary.
  • map fields 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

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