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:
[]
= "0.1"
= { = "1.0", = ["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 ;
use Versioned;
// Define version DTOs
// Define migrations
// Define domain type
// Final migration to domain
// Serialization (domain → representation)
// Usage:
Modes
Infallible Mode
All migrations guaranteed to succeed:
Generates: impl From<Representation> for Domain
Fallible Mode
Migrations can fail (validation, transformation errors):
// `mode = "fallible"` is the default; specify it only when overriding.
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 = from_str?;
let user: User = rep.try_into?;
The transparent = true flag generates custom Serialize/Deserialize implementations that allow direct domain type serialisation:
// Now works directly:
let user: User = from_str?;
let json = to_string?;
Representation Format
Data is serialised with an embedded _version tag:
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