"Data Contracts: A Deep Dive"
Data contracts are the foundation of AimDB's cross-platform Rust capabilities. In this post, we'll explore how they work and why they matter for edge computing with Rust.
The Problem with Traditional Approaches
In a typical IoT architecture, you might have:
- Sensor firmware writing raw bytes to a buffer
- Edge gateway parsing those bytes and reformatting to JSON
- Cloud service deserializing JSON and storing in a database
Each layer has its own data representation, leading to subtle bugs, version mismatches and maintenance headaches.
How Data Contracts Work
AimDB data contracts define a single source of truth using Rust traits:
use aimdb_data_contracts::{SchemaType, Settable, Observable}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Humidity { /// Relative humidity as a percentage (0-100) pub percent: f32, /// Unix timestamp (milliseconds) when reading was taken pub timestamp: u64, } impl SchemaType for Humidity { const NAME: &'static str = "humidity"; const VERSION: u32 = 1; }
The trait system provides:
- Portable serialization — JSON-based wire format via
serdethat works across all platforms, fromno_stdMCU targets to cloud services - Type-safe operations — Traits like
SettableandObservablefor consistent data handling - Schema identity —
NAMEandVERSIONconstants for registration and compatibility checking
Schema Evolution
What happens when you need to make breaking changes? Data contracts support backward-compatible evolution through the MigrationStep trait — typed, bidirectional transforms between concrete version structs, wired together with the migration_chain! macro:
use aimdb_data_contracts::{MigrationStep, MigrationError, migration_chain}; pub struct TemperatureV1ToV2; impl MigrationStep for TemperatureV1ToV2 { type Older = TemperatureV1; type Newer = Temperature; const FROM_VERSION: u32 = 1; const TO_VERSION: u32 = 2; fn up(v1: TemperatureV1) -> Result<Temperature, MigrationError> { let celsius = match v1.unit.as_str() { "F" => (v1.temp - 32.0) * 5.0 / 9.0, _ => v1.temp, }; Ok(Temperature { celsius, timestamp: v1.timestamp }) } fn down(v2: Temperature) -> Result<TemperatureV1, MigrationError> { Ok(TemperatureV1 { schema_version: 1, temp: v2.celsius, timestamp: v2.timestamp, unit: String::from("C"), }) } } migration_chain! { type Current = Temperature; version_field = "schema_version"; steps { TemperatureV1ToV2: TemperatureV1 => Temperature } }
The chain is validated at compile time — version gaps, type mismatches, and broken linkage are all compile errors. See the schema migration blog post for a full walkthrough.
For simple additive changes, just use #[serde(default)] on new optional fields — no migration needed. Old consumers continue working, while new consumers can access the additional data.
Feature-Based Capabilities
Data contracts support additional capabilities through feature flags:
linkable— Serialization for connector links (MQTT, KNX, etc.)simulatable— Generate realistic test data with random walks and trendsmigratable— Typed bidirectional schema migration with compile-time chain validationobservable— Signal extraction for thresholds, alerts, and logging
Because contracts compile for both no_std embedded targets (STM32, Cortex-M) and standard Rust (edge gateways, Kubernetes), you write your data schema once and deploy the same Rust code from MCU to cloud.
Try It Yourself
Check out the Data Contracts documentation for complete API reference and examples.