|
| 1 | +export const author = "nathan-flurry" |
| 2 | +export const published = "2025-09-24" |
| 3 | +export const category = "technical" |
| 4 | +export const keywords = ["bare","serialization","internals"] |
| 5 | + |
| 6 | +# VBARE: A simple alternative to Protobuf & Cap'n Proto for schema evolution |
| 7 | + |
| 8 | +**At Rivet, we're building an open-source alternative to Cloudflare Durable Objects — a tool for running stateful compute workloads. VBARE is a small but crucial component in meeting the demanding performance requirements of Rivet. If you enjoy this article, [consider giving Rivet a star on GitHub](https://github.com/rivet-dev/engine).** |
| 9 | + |
| 10 | +## Growing pains with Protobuf |
| 11 | + |
| 12 | +We decided to adopt Protocol Buffers for any internal communications & data stored at rest. We've always believed it's a flawed technology, but it was the **only mainstream and portable tool** that provided binary serialization and schema evolution — or what we _thought_ would be sufficient schema evolution. |
| 13 | + |
| 14 | +### The problem |
| 15 | + |
| 16 | +Issues started arriving after our v1 launched and we started adding new features that **required significant changes to the datastructures in our Protobufs**. Eventually, all of our Protobuf files started generating bitrot of sorts. They became tedious to read, understand defaults, and understand migration paths. We frequently ended up with a handful of common issues: |
| 17 | + |
| 18 | +- **All new properties need to be optional**: We had to litter our application logic with defaults if a property was none |
| 19 | +- **Properties cannot be moved or restructured**: Say we want to change a bool to an enum or a struct to a union — good luck |
| 20 | +- **Datastructures need to be reshaped**: The most simple example is switching from a list to a map or switching from a bool to a union |
| 21 | + |
| 22 | +More importantly, the more we changed in Protobuf, **more and more cruft had to be added to our application logic** that made adding features a tedious process. |
| 23 | + |
| 24 | +### The crude solution |
| 25 | + |
| 26 | +To solve this, we started following this pattern to allow us to clean up our application logic: |
| 27 | + |
| 28 | +1. Copy and paste the entire Protobuf spec to a v2 |
| 29 | +2. Update the v2 to the ideal schema |
| 30 | +3. Write a migration function between v1 and v2 |
| 31 | +4. Feed the final v2 version into the application logic |
| 32 | + |
| 33 | +This became such a standard practice that most of our API saw a turnover to a new version once every 3-6 months. |
| 34 | + |
| 35 | +### The breaking point |
| 36 | + |
| 37 | +While this process helped simplify our application logic significantly, it required a lot of manual effort to implement the Protobuf migrations. So we started evaluating other options that might provide better schema evolution. |
| 38 | + |
| 39 | +## Evaluating existing options |
| 40 | + |
| 41 | +### Goals |
| 42 | + |
| 43 | +- **Simple** — Minimal features, can be easily forked and modified if needed |
| 44 | +- **Portable** — Cross-language support with a well-defined standard |
| 45 | +- **Fast** — Self-contained binary encoding (think: binary data without keys), ideally with zero-copy reads |
| 46 | +- **Clean SDK** — Ser/de code has a huge impact on the legibility & friction of working with application logic |
| 47 | + |
| 48 | +### Non-Goals |
| 49 | + |
| 50 | +- **Data compactness** — That's what gzip is for |
| 51 | +- **RPC layer** — This is trivial to implement yourself based on your specific requirements |
| 52 | + |
| 53 | +### Exploring existing options |
| 54 | + |
| 55 | +We evaluated numerous serialization protocols before deciding to build VBARE. Here's a brief overview of why each fell short: |
| 56 | + |
| 57 | +- **Protocol Buffers**: Makes migrations your problem at runtime by making everything optional, with dangerous default values that lead to subtle bugs |
| 58 | +- **Flatbuffers**: Similar issues to Protocol Buffers with field indexing requirements, awful codegen |
| 59 | +- **Cap'n Proto**: Provides powerful schema migrations at the cost of complexity, focused on C++, poor generated clients for non-C++ languages |
| 60 | +- **CBOR/MessagePack/BSON**: Self-describing formats, unsuitable for our performance needs |
| 61 | +- **Bebop/Borsh**: Provides cross-language, self-contained binary encoding. We almost chose one of these instead of BARE, but BARE is simpler. |
| 62 | +- **Rust-specific options (postcard, bincode, etc.)**: Not cross-language, only supports Rust |
| 63 | + |
| 64 | +(_For a detailed comparison, see our [full evaluation document](https://github.com/rivet-dev/vbare/blob/e08ca552b3e3703cf23e4764342be772ddc43879/docs/COMPARISON.md)._) |
| 65 | + |
| 66 | +In the end, none of these solutions provided a compelling schema evolution mechanism. |
| 67 | + |
| 68 | +## VBARE overview |
| 69 | + |
| 70 | +Enter, VBARE: a **tiny extension of BARE to provide a version header** and handle version migrations. |
| 71 | + |
| 72 | +Instead of using an off-the-shelf solution, we opted to build a simple evolution system similar to the pattern we were already using: **manually writing code to migrate between schemas**. We would pair this technique with the BARE encoding to create VBARE. |
| 73 | + |
| 74 | +<Info> |
| 75 | + [BARE (Binary Application Record Encoding)](https://baremessages.org/) — which VBARE extends — is a simple binary serialization format designed for efficiency and simplicity. Unlike self-describing formats like JSON or CBOR, BARE requires a schema to encode and decode data, similar to Protocol Buffers but with a much simpler design philosophy. |
| 76 | +</Info> |
| 77 | + |
| 78 | +### Schema evolution in VBARE |
| 79 | + |
| 80 | +VBARE's design philosophy centers on four core beliefs: |
| 81 | + |
| 82 | +1. **Make smaller, incremental schema changes** instead of massive v1 to v2 overhauls. Build tools that make this easy and your schema will be easier to work with. |
| 83 | + |
| 84 | +2. **Manual evolution simplifies application logic** by isolating schema evolution logic to a separate module before passing it to the application logic. |
| 85 | + |
| 86 | +3. **Real-world schema evolutions require more than simple property renaming**: it involves complex reshaping and fetching data from remote sources, which automatic migration systems can't handle. |
| 87 | + |
| 88 | +4. **Manual evolution is less error prone** by forcing developers to explicitly handle edge cases of migrations and breaking changes, trading verbosity for safety. |
| 89 | + |
| 90 | +### Versions |
| 91 | + |
| 92 | +VBARE operates by declaring a schema file for every version of your protocol, then writing explicit conversion functions between adjacent versions. |
| 93 | + |
| 94 | +Each message has an associated version number (unsigned 16-bit integer) that increases monotonically from 1. Versions are specified in the filename: `my-schema/v1.bare`, `my-schema/v2.bare`, etc. |
| 95 | + |
| 96 | +### Converters |
| 97 | + |
| 98 | +Servers define conversion code that transforms between versions bidirectionally: |
| 99 | +- **Upgrade** converters for deserialization (old → new) |
| 100 | +- **Downgrade** converters for serialization (new → old) |
| 101 | + |
| 102 | +There are no evolution semantics in the schema language itself. To create a new version, you simply copy the previous schema and make your changes. |
| 103 | + |
| 104 | +### Version Negotiation |
| 105 | + |
| 106 | +Every message has an associated version, which can be either: |
| 107 | + |
| 108 | +- **Embedded** in the message itself (first 2 bytes) |
| 109 | +- **Pre-negotiated** via HTTP paths (`POST /v3/users`), query parameters, or handshakes |
| 110 | + |
| 111 | +### Servers vs Clients |
| 112 | + |
| 113 | +- **Servers** must include converters between all versions to handle any client version. |
| 114 | + |
| 115 | +- **Clients** only need to include a single version since the server handles all version conversion. |
| 116 | + |
| 117 | +## Use cases |
| 118 | + |
| 119 | +Common use cases include: |
| 120 | + |
| 121 | +- **Network protocols** — Allow the server to cleanly evolve the protocol version without breaking old clients |
| 122 | +- **Data at rest** — Upgrade your file format without breaking old files |
| 123 | + |
| 124 | +For examples, VBARE powers almost all of Rivet: |
| 125 | + |
| 126 | +- **[Rivet Engine](https://github.com/rivet-dev/engine)** |
| 127 | + - [Data at rest](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/data) |
| 128 | + - Internal network protocols ([Epoxy](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/epoxy-protocol), [UPS](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/ups-protocol)) |
| 129 | + - [Public network protocols](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/runner-protocol) |
| 130 | + |
| 131 | +- **[RivetKit](https://github.com/rivet-dev/rivetkit)** |
| 132 | + - [Client protocol](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol) |
| 133 | + - [Persisted state](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/actor-persist) |
| 134 | + - [File system driver](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/file-system-driver) |
| 135 | + |
| 136 | + |
| 137 | +## Example Code |
| 138 | + |
| 139 | +Here's a simple example demonstrating how VBARE handles a complex schema migration that is not possible with any existing tools: |
| 140 | + |
| 141 | +```text {{"title":"schema/v1.bare"}} |
| 142 | +type User struct { |
| 143 | + id: string |
| 144 | + name: string |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +```text {{"title":"schema/v2.bare"}} |
| 149 | +type User struct { |
| 150 | + id: string |
| 151 | + // Split `name` in to 2 properties |
| 152 | + firstName: string |
| 153 | + lastName: string |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +```typescript |
| 158 | +import * as V1 from "./v1"; |
| 159 | +import * as V2 from "./v2"; |
| 160 | +import { createVersionedDataHandler } from "vbare"; |
| 161 | + |
| 162 | +// Converter from v1 to v2 |
| 163 | +function upgradeUserV1ToV2(v1: V1.User): V2.User { |
| 164 | + const [firstName, ...rest] = v1.name.split(' '); |
| 165 | + return { |
| 166 | + ...v1, |
| 167 | + firstName, |
| 168 | + lastName: rest.join(' ') || '' |
| 169 | + }; |
| 170 | +} |
| 171 | + |
| 172 | +// Converter from v2 to v1 |
| 173 | +function downgradeUserV2ToV1(v2: V2.User): V1.User { |
| 174 | + return { |
| 175 | + ...v2, |
| 176 | + name: `${v2.firstName} ${v2.lastName}`.trim() |
| 177 | + }; |
| 178 | +} |
| 179 | + |
| 180 | +// Create versioned data handler |
| 181 | +export const USER_VERSIONED = createVersionedDataHandler<V2.User>({ |
| 182 | + deserializeVersion: (bytes: Uint8Array, version: number): any => { |
| 183 | + switch (version) { |
| 184 | + case 1: |
| 185 | + return V1.decodeUser(bytes); |
| 186 | + case 2: |
| 187 | + return V2.decodeUser(bytes); |
| 188 | + default: |
| 189 | + throw new Error(`invalid version: ${version}`); |
| 190 | + } |
| 191 | + }, |
| 192 | + serializeVersion: (data: any, version: number): Uint8Array => { |
| 193 | + switch (version) { |
| 194 | + case 1: |
| 195 | + return V1.encodeUser(data); |
| 196 | + case 2: |
| 197 | + return V2.encodeUser(data); |
| 198 | + default: |
| 199 | + throw new Error(`invalid version: ${version}`); |
| 200 | + } |
| 201 | + }, |
| 202 | + deserializeConverters: () => [upgradeUserV1ToV2], |
| 203 | + serializeConverters: () => [downgradeUserV2ToV1], |
| 204 | +}); |
| 205 | + |
| 206 | +``` |
| 207 | + |
| 208 | +## Implementations |
| 209 | + |
| 210 | +- **[TypeScript](https://github.com/rivet-dev/vbare/tree/main/typescript)** — [Example Code](https://github.com/rivet-dev/vbare/blob/main/typescript/examples/basic/src/index.ts) |
| 211 | +- **[Rust](https://github.com/rivet-dev/vbare/tree/main/rust)** — [Example Code](https://github.com/rivet-dev/vbare/blob/main/rust/examples/basic/src/lib.rs) |
| 212 | + |
| 213 | +For a full list of BARE implementations, visit [baremessages.org](https://baremessages.org/). |
| 214 | + |
| 215 | +## FAQ |
| 216 | + |
| 217 | +### Why is copying the entire schema for every version better than using decorators for gradual migrations? |
| 218 | + |
| 219 | +- Decorators are limited and become very complicated over time |
| 220 | +- It's unclear at what version of the protocol a decorator takes effect — explicit versions help clarify this |
| 221 | +- Generated SDKs become more and more bloated with every change |
| 222 | +- You need a validation build step for your validators |
| 223 | +- Manual migrations provide more flexibility for complex transformations |
| 224 | + |
| 225 | +### Why not include RPC? |
| 226 | + |
| 227 | +RPC interfaces are trivial to implement yourself. Libraries that provide RPC interfaces tend to add extra bloat and cognitive load through things like abstracting transports, compatibility with the language's async runtime, and complex codegen to implement handlers. |
| 228 | + |
| 229 | +Usually, you just want a `ToServer` and `ToClient` union that looks like this: |
| 230 | +- [ToClient example](https://github.com/rivet-dev/rivetkit/blob/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol/v1.bare#L34) |
| 231 | +- [ToServer example](https://github.com/rivet-dev/rivetkit/blob/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol/v1.bare#L56) |
| 232 | + |
| 233 | +### Don't migration steps get repetitive? |
| 234 | + |
| 235 | +Migration steps are fairly minimal to write. The most verbose migration steps will be for deeply nested structures that changed, but even that is relatively straightforward. |
| 236 | + |
| 237 | +### What are the downsides? |
| 238 | + |
| 239 | +- More verbose migration code — but this is usually because VBARE forces you to handle all edge cases you wouldn't otherwise bother with |
| 240 | +- The older the version, the more migration steps that need to run to bring it to the latest version — though migration steps are usually negligible in cost |
| 241 | +- Migration steps are not portable across languages, but only the server needs the migration steps, so this is usually only implemented once |
| 242 | + |
| 243 | +## Getting started & source code |
| 244 | + |
| 245 | +VBARE is open source and [available on GitHub](https://github.com/rivet-dev/vbare). |
| 246 | + |
| 247 | +See the guides for getting started with [TypeScript](https://github.com/rivet-dev/vbare/tree/main/typescript) and [Rust](https://github.com/rivet-dev/vbare/tree/main/rust). |
| 248 | + |
0 commit comments