Expand description
§serde-evolve - Type-Safe Data Schema Evolution
A Rust library for versioning serialised data structures with compile-time verified migrations.
§Overview
serde-evolve helps you evolve data schemas over time while maintaining backward compatibility with historical data. It separates wire format (serialization) from domain types (application logic), allowing you to deserialise any historical version and migrate it to your current domain model.
§Installation
Add this to your Cargo.toml:
[dependencies]
serde-evolve = "0.1"
serde = { version = "1.0", features = ["derive"] }§Key Features
- ✅ Compile-time safety: Type-checked migration chains
- ✅ Standard Rust traits: Uses
From/TryFrom, no custom APIs - ✅ Clean separation: Representation types stay separate from domain logic
- ✅ Framework-agnostic: Works with any serde format (JSON, bincode, etc.)
- ✅ Fallible migrations: Support validation and transformation errors
- ✅ Simple macro: Generate boilerplate using a derive macro
§Quick Example
use serde::{Deserialize, Serialize};
use serde_evolve::Versioned;
// Define version DTOs
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserV1 {
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserV2 {
pub full_name: String,
pub email: Option<String>,
}
// Define migrations
impl From<UserV1> for UserV2 {
fn from(v1: UserV1) -> Self {
Self {
full_name: v1.name,
email: None,
}
}
}
// Define domain type
#[derive(Clone, Debug, Versioned)]
#[versioned(
mode = "infallible",
chain(UserV1, UserV2),
)]
pub struct User {
pub full_name: String,
pub email: Option<String>,
}
// Final migration to domain
impl From<UserV2> for User {
fn from(v2: UserV2) -> Self {
Self {
full_name: v2.full_name,
email: v2.email,
}
}
}
// Serialization (domain → representation)
impl From<&User> for UserV2 {
fn from(user: &User) -> Self {
Self {
full_name: user.full_name.clone(),
email: user.email.clone(),
}
}
}
// Usage:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let json_v1 = r#"{"_version":"1","name":"Alice"}"#;
let rep: UserVersions = serde_json::from_str(json_v1)?;
let user: User = rep.into(); // Automatic migration V1 → V2 → User
Ok(())
}§Modes
§Infallible Mode
All migrations guaranteed to succeed:
#[versioned(mode = "infallible", chain(V1, V2))]Generates: impl From<Representation> for Domain
§Fallible Mode
Migrations can fail (validation, transformation errors):
// `mode = "fallible"` is the default; specify it only when overriding.
#[versioned(error = MyError, chain(V1, V2))]Generates: impl TryFrom<Representation> for Domain
§Transparent Serde Support
By default, you work explicitly with the representation enum:
// Default behavior - explicit representation
let rep: UserVersions = serde_json::from_str(json)?;
let user: User = rep.try_into()?;The transparent = true flag generates custom Serialize/Deserialize implementations that allow direct domain type serialisation:
#[versioned(
mode = "infallible",
chain(V1, V2),
transparent = true // ← Enable transparent serde
)]
pub struct User {
pub name: String,
}
// Now works directly:
let user: User = serde_json::from_str(json)?;
let json = serde_json::to_string(&user)?;§Representation Format
Data is serialised with an embedded _version tag:
{
"_version": "1",
"name": "Alice"
}Serde’s #[serde(tag = "_version")] handles routing to the correct variant.
§Design Principles
- Representation/Domain Separation: Domain types never leak serialisation concerns
- Standard Traits: Uses Rust’s
From/TryInto, not custom APIs - Type Safety: Missing migrations cause compile errors
- User Control: You define all version structs and migrations
§Architecture
┌─────────────────────────────────────────────────┐
│ Historical Data (V1, V2, ...) │
└────────────────┬────────────────────────────────┘
│ Deserialize
▼
┌─────────────────────────────────────────────────┐
│ Representation Enum (auto-generated) │
│ ┌─────────────────────────────────────────┐ │
│ │ enum UserVersions { │ │
│ │ V1(UserV1), │ │
│ │ V2(UserV2), │ │
│ │ } │ │
│ └─────────────────────────────────────────┘ │
└────────────────┬────────────────────────────────┘
│ From/TryFrom (chain migrations)
▼
┌─────────────────────────────────────────────────┐
│ Domain Type (your application logic) │
│ struct User { ... } │
└─────────────────────────────────────────────────┘§Generated Code
The #[derive(Versioned)] macro generates:
- Representation enum with serde tags
From<Representation> for Domain(orTryFromfor fallible)From<&Domain> for Representation(for serialization)- Helper methods:
version(),is_current(),CURRENT
§Use Cases
- Event sourcing: Immutable event streams that must be replayable
- Message queues: Long-lived messages with evolving schemas
- API versioning: Supporting multiple client versions
- Data archives: Historical records that must remain accessible
- Configuration files: Version migrations for user settings
Derive Macros§
- Versioned
- Derive macro for versioned data structures.