Back to Blog
technicaltutorials

"Data Contracts: A Deep Dive"

January 20, 2026AimDB Team6 min read

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:

  1. Portable serialization — JSON-based wire format via serde that works across all platforms, from no_std MCU targets to cloud services
  2. Type-safe operations — Traits like Settable and Observable for consistent data handling
  3. Schema identityNAME and VERSION constants 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 trends
  • migratable — Typed bidirectional schema migration with compile-time chain validation
  • observable — 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.