Keep schema history out of your application model.
Book · Documentation · Examples · Changelog
The Problem
Persisted data often outlives the code that wrote it. As an application evolves, fields may be renamed, added, removed, or change meaning.
One approach is to make the current deserializer accept every historical shape.
As the schema changes, compatibility rules accumulate, especially for
independently evolving nested types, and the deserialization layer becomes
harder to maintain. Furthermore, without explicit schema versions, the
deserializer cannot know which semantics to apply when meaning changes:
status: "active" might have meant that the account was enabled in previous
versions, but now means its subscription is paid and current. Both records
decode to the same Rust type.
thisversion gives each schema version its own Rust type, separating the work
of decoding a schema from migrating it into the application model. Explicit
conversions make migrations expressive and independently testable, including
changes in meaning as well as structure. Rust checks the conversions between
the latest schema and application model, so incompatible model changes fail at
compile time. This lets you evolve the model with confidence: when older data
must remain compatible, express the change as a new schema version and
migration.
Installation
Simply add thisversion to your Cargo.toml:
[]
= "0.1"
For the example below you will also need serde, and serde_json.
Features
The optional serde feature provides convenience adapters when you want to
deserialize directly into the application model. This is not needed for the
example below.
The optional alloc feature allows a Vec of versioned data to be transformed
between model and schema. This is not needed for the example below.
Basic usage
Represent each persisted schema as a Rust type, then list them in a
VersionFamily enum from oldest to newest. Derive VersionBoundary on the latest
schema to connect the version family to the application model.
Migrations connect each schema version to the next using From or TryFrom.
The application works with one current model; thisversion handles the
versioned representations at the persistence boundary.
use Duration;
use ;
use ;
//===========================================================================//
// APPLICATION //
//===========================================================================//
// The application model; free of any versioning history.
// The application model; free of any versioning history.
//===========================================================================//
// VERSIONS //
//===========================================================================//
// All this historical complexity can be abstracted away from the day-to-day
// business logic while also gaining maintainability and testability.
//
// A change in the application model will cause a compiler error prompting the
// developer to declare a new version.
use ;
// V1 stored the name as `label`, timeout in seconds, and account state as
// `status`.
// V2 uses `name` and changed the timeout unit, adds retries, and gives
// account state an explicit meaning.
// V1 had no retry count; its `active` status meant the account was enabled.
// V3 reuses the wire field `status` for subscription state. We also separate
// the wire format of `timeout` from the more useful Rust model.
// Settings had no subscription state, so the migration policy treats it as
// canceled.
// Subscription state has its own history, so it can evolve independently of
// the containing schema.
// The latest persisted representation maps directly to the application enum.
// The Rust model uses Duration; the wire format uses an integer count of
// milliseconds.
Persistence
thisversion is agnostic about the storage system, wire format, and version
identifier. Persisted data only needs a way to select its schema. In the
previous example, Serde's V1, V2, or V3 tag selects the schema; Serde
decodes its fields, and thisversion migrates it into the current application
model. Serde is optional: migrations can be used with other serialization
formats or storage systems.
The optional serde feature also provides adapters for deserializing directly
into the application model.
Nesting
Large persisted structures often contain parts with different rates of change. If every change to a child required a new version of its parent, the parent's history would grow for changes that do not affect the parent itself. Over time, this couples unrelated migrations and makes each parent schema responsible for details of its children.
A nested version family gives that child an independent history. The parent stores the child's version family, and migration composes automatically: when loading, the parent reaches its latest schema and the nested value reaches its current model. Saving selects the latest schema in both families. Each migration can focus on the part of the data it owns.
This separation is especially useful when a child is shared by multiple parent types. Its schema changes and migration rules can be defined once and reused, while each parent keeps a history focused on its own fields. Applications still work with the current child model directly, without carrying version wrappers through business logic.
More examples
The generic containers, fallible migrations, and convergent schemas examples cover additional conversion patterns.
FAQ
Why the name?
thisversion takes inspiration from thiserror
where both crates aim to make a focused part of Rust development easier to
express.