Back to Blog
technicalarchitecture

"Streamable: One Trait to Cross Every Boundary"

March 3, 2026AimDB Team6 min read

Every serialization boundary in a data system creates the same pressure: define your types twice, once on each side, and keep them in sync as schemas evolve. REST APIs live with OpenAPI specs that drift from implementation. gRPC codegen works but ties you to a build step. WASM bindings in particular tend to produce a parallel TypeScript type system that mirrors every Rust struct.

AimDB's Streamable trait takes a different approach. It doesn't eliminate serialization at boundaries — it makes the Rust type the single source of truth at every boundary, so there's nothing to keep in sync.

The Problem in Concrete Terms

Suppose you have three consumers of a data contract: a WebSocket connector that publishes it over the wire, a WASM adapter that exposes it to the browser and a CLI that can query it by name. Without a shared mechanism, each of these needs to know, independently, which types exist and how to dispatch to them.

The naive solution is to maintain three separate lists of types — one per consumer. Every time you add a new contract, you update three places. Miss one and you get a runtime failure in a consumer that claims it doesn't know about your new type.

The Streamable trait solves this with a visitor pattern. There is one registry, one function that enumerates all streamable types, and every consumer implements a single trait to get what it needs from that enumeration.

The Trait

Streamable lives in aimdb-data-contracts and is a capability marker — a trait with no methods of its own:

pub trait Streamable: SchemaType + Serialize + DeserializeOwned + Send + Sync + Clone + Debug + 'static {}

It bundles SchemaType (which provides a unique NAME string and a VERSION number) with the serde bounds needed for serialization at boundaries. A type that implements Streamable is declaring: "I can be transported across a wire boundary, and the receiving end can enforce my schema in Rust."

The built-in contracts implement it with a single line each:

impl Streamable for Temperature {} impl Streamable for Humidity {} impl Streamable for GpsLocation {}

No methods to implement. No codegen. The supertrait bounds do all the work — if your type doesn't derive Serialize + Deserialize and implement SchemaType, the compiler tells you immediately.

The Visitor Pattern

The companion to Streamable is StreamableVisitor:

pub trait StreamableVisitor { fn visit<T: Streamable>(&mut self); }

And the single source of truth — one function that calls visit for every registered type:

pub fn for_each_streamable(visitor: &mut impl StreamableVisitor) { visitor.visit::<Temperature>(); visitor.visit::<Humidity>(); visitor.visit::<GpsLocation>(); }

This is the entire registry. There's no runtime HashMap, no inventory macro, no distributed slice. When a consumer needs to build a dispatch table — mapping a schema name string to concrete type behavior — it implements StreamableVisitor and calls for_each_streamable once at startup.

A Real Consumer: The Schema Registry

The WASM adapter and WebSocket connector both use a SchemaRegistry built this way. Here's the visitor implementation that populates it:

struct RegistryBuilder { registry: HashMap<&'static str, Box<dyn StreamableHandler>>, } impl StreamableVisitor for RegistryBuilder { fn visit<T: Streamable>(&mut self) { self.registry.insert(T::NAME, Box::new(TypedHandler::<T>::new())); } } let mut builder = RegistryBuilder { registry: HashMap::new() }; for_each_streamable(&mut builder); let registry = builder.registry;

After this, registry.get("temperature") returns a handler that knows how to deserialize a JSON payload into a Temperature, validate it, and route it to the right AimDB buffer — all without the call site knowing the concrete type.

Another Consumer: Listing Types for the CLI

A CLI that wants to display all available schema names needs a much simpler visitor:

struct NameCollector { names: Vec<&'static str>, } impl StreamableVisitor for NameCollector { fn visit<T: Streamable>(&mut self) { self.names.push(T::NAME); } } let mut collector = NameCollector { names: Vec::new() }; for_each_streamable(&mut collector); // collector.names == ["temperature", "humidity", "gps_location"]

Different consumer, same registry, no duplication.

Adding a New Contract: Four Steps

The design deliberately makes adding a type mechanical:

// 1. Define the struct in contracts/ #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AirPressure { pub schema_version: u32, pub hpa: f32, pub timestamp: u64, } // 2. Implement SchemaType with a unique NAME impl SchemaType for AirPressure { const NAME: &'static str = "air_pressure"; const VERSION: u32 = 1; } // 3. Mark it as Streamable impl Streamable for AirPressure {} // 4. Add it to the registry function pub fn for_each_streamable(visitor: &mut impl StreamableVisitor) { visitor.visit::<Temperature>(); visitor.visit::<Humidity>(); visitor.visit::<GpsLocation>(); visitor.visit::<AirPressure>(); // ← this line }

That's it. The WASM adapter, the WebSocket connector, the CLI and any future consumer all pick up AirPressure automatically — no changes to consumer code required.

Why Not a Macro or Inventory?

An alternative design would use a distributed registration macro — something like #[register_streamable] on each type, with a linker-level aggregation step. This works in some contexts but has costs: it's harder to reason about (the registry is implicit), it requires build tooling support and it can create link-order dependencies that differ between platforms.

The visitor approach is explicit and portable. The registry is a function you can read. Adding a type is a visible change to a visible file. The compiler verifies every visit call matches the Streamable bound — there's no way to register a type that doesn't satisfy the requirements.

For a system that targets no_std Embassy on microcontrollers as well as WASM in the browser, explicit beats implicit.

The Boundary Guarantee

What Streamable ultimately buys is contract enforcement at every serialization boundary without rebuilding the enforcement logic per consumer.

A JSON payload that arrives over WebSocket claiming to be "temperature" is deserialized into the Rust Temperature struct before it enters any buffer. A JavaScript object written to the WASM layer claiming to match "humidity" is deserialized into Humidity in Rust before it's stored. If either payload violates the struct definition — wrong field type, missing required field, unknown schema name — it fails before it touches the dataflow engine.

The contract isn't advisory. It's enforced by serde at the boundary, derived from the same Rust struct your producers write to and your consumers read from. Streamable is what makes that happen uniformly, at every boundary, from a single four-line registration.


The Streamable trait is defined in aimdb-data-contracts. The visitor-based SchemaRegistry that powers the WASM adapter and WebSocket connector lives in the same crate. Browse the source on GitHub or open an issue if you have questions about integrating a new transport boundary.

Have questions? Open an issue on GitHub or join the discussion.