Proto Wrapper Plugin

  • java
  • protobuf
  • maven
  • gradle
  • code-generation
Proto Wrapper Plugin

Proto Wrapper Plugin generates a unified Java API from multiple protobuf schema versions. Instead of writing version-specific code everywhere, you work with clean interfaces that abstract away the differences.

The Problem It Solves

Long-lived systems accumulate protocol versions. Field types change (int32 becomes enum), new fields appear, structures evolve. Without tooling, you end up with code like this scattered everywhere:

if (version == 1) {
    int type = requestV1.getPaymentType();
    process(type);
} else if (version == 2) {
    PaymentType type = requestV2.getPaymentType();
    process(type.getNumber());
}

Proto Wrapper generates wrappers that handle this automatically:

Payment payment = ctx.wrapPayment(anyVersionProto);
int type = payment.getPaymentType();  // Works with any version

Architecture

The plugin works in three phases:

flowchart TD
    P[Proto Files
v1, v2, v3] P --> PROTOC[protoc] PROTOC --> PARSER[Schema Parser] PARSER --> MERGER[Version Merger] MERGER --> IF[Interfaces] MERGER --> AC[Abstract Classes] MERGER --> IMPL[Implementations] MERGER --> CTX[VersionContext]

Phase 1: Schema Parsing The plugin runs protoc on each version directory, extracting message structures, field types, and relationships.

Phase 2: Version Merging Schemas are merged into a unified model. Fields with the same name/number are combined. Type conflicts are detected and classified.

Phase 3: Code Generation JavaPoet generates clean Java code: interfaces for version-agnostic access, abstract classes with template methods, and version-specific implementations.

Conflict Resolution

When field types differ between versions, the plugin generates appropriate accessors:

Conflict TypeExampleResolution
INT → ENUMint32enumDual getters: getType() + getTypeEnum()
WIDENINGint32int64Wider type with validation
PRIMITIVE → MESSAGEint64MoneygetTotal() + getTotalMessage()
STRING → BYTESstringbytesgetText() + getTextBytes()
FLOAT → DOUBLEfloatdoubleUnified as double
SIGNED → UNSIGNEDint32uint32Unified as long

Renumbered Fields Support

Google's protobuf guidelines strongly advise against changing field numbers — it breaks wire compatibility. But in the real world, legacy systems accumulate technical debt, and sometimes you inherit a codebase where this already happened.

The plugin doesn't encourage renumbering. It handles the cases where it already exists.

Automatic Detection

The schema diff tool heuristically detects suspected renumbered fields:

flowchart TD
    START([Field Change]) --> CHECK{Same name in
REMOVED and ADDED?} CHECK -->|Yes| TYPE{Same type?} CHECK -->|No| BREAKING[Breaking Change] TYPE -->|Yes| HIGH[HIGH confidence
Suggested mapping] TYPE -->|No| COMPAT{Compatible type?} COMPAT -->|Yes| MEDIUM[MEDIUM confidence
int→enum, float→double] COMPAT -->|No| BREAKING

Detection strategies:

  • REMOVED+ADDED pairs — same field name appears in both removed and added lists
  • Displaced fields — a removed field's name matches a renamed field

Explicit Field Mappings

For production use, configure explicit mappings to suppress false positives:

<configuration>
    <fieldMappings>
        <fieldMapping>
            <message>TicketRequest</message>
            <fieldName>parent_ticket</fieldName>
            <versionNumbers>
                <v1>17</v1>
                <v2>15</v2>
            </versionNumbers>
        </fieldMapping>
    </fieldMappings>
</configuration>

Behavior with mappings:

  • Mapped fields show as ~ Renumbered: fieldName #17 → #15 [MAPPED]
  • Treated as INFO-level (not breaking change)
  • Summary shows Renumbers: 1 mapped, 0 suspected

Field Contracts

One of the most complex aspects of multi-version protobuf is understanding field behavior. Does a getter return null? Is there a has*() method? The answer depends on proto syntax, field type, and presence semantics.

The plugin uses a Contract Matrix to systematize field behavior:

flowchart TD
    START([Field]) --> IS_REPEATED{Repeated or Map?}

    IS_REPEATED -->|Yes| REPEATED[Never null
Default: empty] IS_REPEATED -->|No| IS_ONEOF{In oneof?} IS_ONEOF -->|Yes| ONEOF[Nullable
has method: YES] IS_ONEOF -->|No| IS_MESSAGE{Message type?} IS_MESSAGE -->|Yes| MESSAGE[Nullable
has method: YES] IS_MESSAGE -->|No| SYNTAX{Proto syntax?} SYNTAX -->|Proto2| P2_LABEL{Label?} P2_LABEL -->|optional| P2_OPT[Nullable
has method: YES] P2_LABEL -->|required| P2_REQ[Not null
has method: YES] SYNTAX -->|Proto3| P3_OPT{optional keyword?} P3_OPT -->|Yes| P3_EXPLICIT[Nullable
has method: YES] P3_OPT -->|No| P3_IMPLICIT[Not null
has method: NO]

Multi-Version Merge Rules

When a field exists in multiple versions with different characteristics:

PropertyMerge RuleExample
hasMethodALL versions must have itv1:YES + v2:NO → NO
nullableANY version nullablev1:YES + v2:NO → YES
CardinalityHigher winssingular + repeated → repeated

This ensures the unified API is safe across all versions — if any version can return null, the wrapper handles it.

Generated Getter Patterns

// Nullable field with has method:
public String getNickname() {
    return extractHasNickname(proto) ? extractNickname(proto) : null;
}

// Non-nullable field (proto3 implicit):
public String getName() {
    return extractName(proto);  // Never null, returns "" if unset
}

// Repeated field:
public List<Item> getItems() {
    return extractItems(proto);  // Never null, returns [] if empty
}

Generated Code Structure

com.example.model/
├── api/                           # Version-agnostic
│   ├── Order.java                 # Interface
│   ├── OrderType.java             # Unified enum
│   ├── VersionContext.java        # Factory interface
│   ├── ProtocolVersions.java      # Version constants
│   └── impl/
│       └── AbstractOrder.java     # Template methods
├── v1/
│   ├── OrderV1.java               # V1 implementation
│   └── VersionContextV1.java      # V1 factory
├── v2/
│   ├── OrderV2.java               # V2 implementation
│   └── VersionContextV2.java      # V2 factory
└── v3/
    ├── OrderV3.java
    └── VersionContextV3.java

Interfaces define the version-agnostic API. All accessors, builders, serialization.

Abstract classes implement common logic using the Template Method pattern. Version-specific extraction is delegated to abstract extract* methods.

Implementation classes provide version-specific logic. Each one wraps the actual protobuf message for its version.

VersionContext is the entry point. It wraps raw protos and creates builders for the correct version.

Design Patterns

The codebase uses several patterns that make it maintainable:

Template Method — Abstract classes define the algorithm skeleton, implementations fill in version-specific details.

Chain of Responsibility — Field processing delegates to specialized handlers based on conflict type.

Strategy — Each conflict handler implements a specific code generation strategy.

Factory — VersionContext provides version-aware object creation.

Key Features

Incremental builds — Only regenerate when proto files actually change. 50%+ faster rebuilds on large projects.

Embedded protoc — No need to install protoc. The plugin downloads the right binary for your platform automatically.

Builder pattern — Full support for creating and modifying messages:

Order order = Order.newBuilder(ctx)
    .setOrderId("ORD-001")
    .setTotal(Money.newBuilder(ctx)
        .setAmount(1000)
        .setCurrency("USD")
        .build())
    .build();

Well-known typesgoogle.protobuf.Timestamp becomes java.time.Instant. Duration becomes java.time.Duration. No manual conversion needed.

Schema diff tool — Compare versions, detect breaking changes, integrate with CI:

mvn proto-wrapper:diff -Dv1=proto/v1 -Dv2=proto/v2 -DfailOnBreaking=true

Spring Boot Starter — Auto-configuration for Spring Boot 3+ with per-request version context.

Tech Stack

ComponentTechnology
LanguageJava 17+
Code GenerationJavaPoet
Proto Parsingprotobuf-java + protoc
Maven Pluginmaven-plugin-api
Gradle PluginKotlin DSL
TestingJUnit 5, AssertJ
CI/CDGitHub Actions

Quick Start

Maven:

<plugin>
    <groupId>io.alnovis</groupId>
    <artifactId>proto-wrapper-maven-plugin</artifactId>
    <version>2.2.0</version>
    <configuration>
        <basePackage>com.example.model</basePackage>
        <protoRoot>${basedir}/proto</protoRoot>
        <versions>
            <version><protoDir>v1</protoDir></version>
            <version><protoDir>v2</protoDir></version>
        </versions>
    </configuration>
    <executions>
        <execution><goals><goal>generate</goal></goals></execution>
    </executions>
</plugin>

Gradle:

plugins {
    id("io.alnovis.proto-wrapper") version "2.2.0"
}

protoWrapper {
    basePackage.set("com.example.model")
    protoRoot.set(file("proto"))
    versions {
        version("v1")
        version("v2")
    }
}

Links