Protobuf has no null: evolving a schema without breaking old messages
Adding a field, making it optional, making it required, removing it, adding a sealed subclass... each of these is a decision about bytes that were written months ago. How k2pb makes those decisions explicit.
This is the second article about k2pb. The first one explains why it exists.
On a point of sale, messages live long. An order serialized on Monday can be read again on Friday by a newer version of the app, or by a backend deployed in between. So every change to a model raises the same question: what will the new code read from the old bytes?
Protobuf gives you the basic rules: fields are identified by number, unknown fields are skipped, missing fields read as their default. That’s enough as long as your language has no null. Kotlin has one.
The ambiguity
In Protobuf, a scalar field equal to its default (0, "", false, first enum entry) is not written on the wire. Now take these two evolutions of a message:
// v1
@ProtoMessage
data class Payment(val amount: Long)
// v2, scenario A: a new nullable field is added
@ProtoMessage
data class Payment(val amount: Long, val method: PaymentMethod?)
// v2, scenario B: an existing field becomes nullable
@ProtoMessage
data class Payment(val amount: Long?)
In both cases, some v1 messages contain nothing for the field:
- in A, because the field didn’t exist yet: the right answer is
null, - in B, because
amountwas0and defaults are not encoded: the right answer is0.
The bytes are strictly identical. The expected values are different. The difference lives in your Git history, not in the message. No library can guess it, so k2pb asks you.
Explicit nullability on the wire
For every nullable field, k2pb writes a second field next to the value:
message Payment {
int64 amount = 1;
PaymentMethod method = 2;
com.glureau.k2pb.ExplicitNullability isMethodNull = 3;
}
ExplicitNullability has three values:
| Value | Meaning |
|---|---|
UNKNOWN | Nothing written: the message predates the field |
NULL | Explicitly null |
NOT_NULL | Explicitly set. The value may be a Protobuf default ("" is not null) |
Since UNKNOWN is the enum’s default (and so the value read when nothing is on the wire), any message written before the nullability field existed reads as UNKNOWN. And that’s where your decision comes in.
NullabilityMigration: say what old messages mean
@ProtoMessage
data class Payment(
val amount: Long,
@ProtoField(nullabilityMigration = NullabilityMigration.NULL)
val method: PaymentMethod?,
)
NULL: use it when adding a nullable field.UNKNOWNdecodes tonull.DEFAULT(the default): use it when an existing field becomes nullable.UNKNOWNdecodes to the Protobuf default, which is what was really encoded. It’s also what pure Protobuf would do.
This is the decoding generated by k2pb for an enum field with NULL:
enum = when (isEnumNull) {
ExplicitNullability.UNKNOWN -> null
ExplicitNullability.NULL -> null
ExplicitNullability.NOT_NULL -> enum ?: AnEnum.AnEnum_A
}
and with DEFAULT:
enum = when (isEnumNull) {
ExplicitNullability.UNKNOWN -> enum ?: AnEnum.AnEnum_A
ExplicitNullability.NULL -> null
ExplicitNullability.NOT_NULL -> enum ?: AnEnum.AnEnum_A
}
One line of difference, and it’s the line that would have been a production bug. Note the NOT_NULL -> enum ?: AnEnum_A: when the value is the Protobuf default, it isn’t on the wire, but we know it’s not null, so we rebuild it.
Making a field required again
The opposite migration: method: PaymentMethod? becomes method: PaymentMethod. The isMethodNull field is now useless, but its number must never be reused for something else, or old messages would be misread. k2pb makes you declare it:
@ProtoMessage(
deprecatedNullabilityFields = [
DeprecatedNullabilityField(
protoName = "method",
protoNumber = 3,
deprecationReason = "Field 'method' has been made required",
),
]
)
data class Payment(val amount: Long, val method: PaymentMethod)
and documents it in the generated .proto:
message Payment {
int64 amount = 1;
PaymentMethod method = 2;
// Deprecated field isMethodNull 3
// Field 'method' has been made required
com.glureau.k2pb.ExplicitNullability isMethodNull = 3;
}
Removing a field
Same principle with DeprecatedField. You choose whether the field stays visible in the .proto (as documentation for the other teams) or becomes a reserved name and number:
@ProtoMessage(
deprecatedFields = [
DeprecatedField(
protoName = "terminalId",
protoNumber = 5,
deprecationReason = "Moved to Device",
publishedInProto = false,
),
]
)
message Payment {
// Removed field terminalId (number=5)
// Moved to Device
reserved "terminalId";
reserved 5;
...
}
And if a number simply disappears without a declaration, k2pb doesn’t stay silent: the generated file says // The protoNumber 4 is not defined, if it's not used anymore consider using deprecated annotation. That shows up in code review, which is exactly where you want it.
Sealed classes and oneof
Sealed hierarchies map naturally to oneof:
@ProtoMessage("Vehicle")
sealed class Vehicle {
@ProtoMessage("Vehicle.Car")
data class Car(val brand: String) : Vehicle()
@ProtoMessage("Vehicle.Bike")
data class Bike(val brand: String) : Vehicle()
}
message Vehicle {
oneof vehicle {
Vehicle.Bike bike = 1;
Vehicle.Car car = 2;
}
...
}
By default children are numbered alphabetically. Convenient to start, dangerous later: add a Bus and Car silently moves from 2 to 3. Once a hierarchy is persisted or shared, pin the numbers:
@ProtoMessage(
name = "Vehicle",
sealedProtoNumbers = [
ProtoMessage.SealedChild(Vehicle.Bike::class, 1),
ProtoMessage.SealedChild(Vehicle.Car::class, 2),
]
)
sealed class Vehicle { ... }
For interfaces or open hierarchies that aren’t sealed, @ProtoPolymorphism declares the children and their numbers explicitly, and supports deprecated children the same way.
Forward compatibility: the unknown field hook
The last case is the reverse one: an old app reads a new message. Unknown field numbers are skipped, as Protobuf requires, but k2pb lets you know through onUnknownProtoNumber(instanceClass, protoNumber). In a POS where an older terminal may receive data from a newer backend, that’s a cheap and very useful signal to log.
Takeaways
- Protobuf has no
null, Kotlin does: the mapping between them is a design decision, not an implementation detail. - “Field absent” means different things depending on the history of the field. Make that history explicit in the code (
NullabilityMigration), not in someone’s memory. - Never reuse a field number. Declare what you remove, let the generator write
reserved. - Pin
oneofnumbers before the first message is persisted. - Test migrations: k2pb’s own test suite encodes with the before class and decodes with the after class (
assertMigration(before, expectedAfter)). Do the same for your models.