Engineering · Apr 20, 2026
SurrealDB 3 & serialization
Over the last month, we made a bunch of changes to Scylla and its architecture. One of our biggest wins was with the release of SurrealDB 3.0, which allowed us to completely remove an important chunk of our code, by directly serializing our domain entities to the database while sticking with Clean architecture.
The old shape
With version 2.x, every domain entity carried a Serialize/Deserialize derive. When we wrote a User, the SDK ran it through serde_json, produced a serde_json::Value, then converted that into its own internal value tree. Two things about it felt wrong:
- IDs were stringly-typed. Our
UserId,AgentId, and so on wereStringwrappers that became JSON strings on the wire. The SDK couldn’t tell a user record ID from an organization record ID from a random"…". - The domain crate knew about JSON. A serialization format sat in the middle of our domain types that had nothing to do with our domain.
What v3 changed
SurrealDB 3 introduces a SurrealValue trait in a new surrealdb-types crate. It’s the SDK’s native value representation, the same tree the server sees, and types either derive it or implement it by hand. There’s no JSON in the middle anymore.
Typed record IDs
Our IDs are generated by a define_id! macro. In v3 we taught that macro about SurrealValue:
impl surrealdb_types::SurrealValue for $name {
fn kind_of() -> surrealdb_types::Kind {
surrealdb_types::Kind::Record(vec![Table::from($table)])
}
fn into_value(self) -> surrealdb_types::Value {
Value::RecordId(RecordId::new($table, self.0))
}
fn from_value(value: Value) -> Result<Self, Error> { /* … */ }
}kind_of() is the key. Every UserId declares “I am a record in the users table,” and the SDK carries that information all the way into SurrealQL. Passing a UserId where the query expects an OrganizationId is rejected at the value layer, not at the database three network hops later.
For RELATE queries this is especially nice. Our membership edges look like:
self.db
.query("RELATE $user_id -> user_organization -> $org_id")
.bind(("user_id", user_id.clone().into_value()))
.bind(("org_id", org_id.clone().into_value()))
.await?;into_value() produces a typed RecordId the SDK uses directly. No thing("users:…") formatting, no stringly-typed bindings, no risk of a malformed ID silently becoming a string literal in the query.
No double encoding
When we .content(user) on a create, the value goes from User to SurrealValue to SurrealDB in one hop. serde_json is gone from the database path, and with it the whole category of bugs where a type round-trips through JSON and comes back slightly wrong: numeric precision, enum shapes, timestamps.
A feature flag where serde used to sit
Because SurrealValue is the SDK’s type, implementing it on a domain type is a coupling to SurrealDB. We didn’t want that by default, so we put the derives behind a surrealdb feature:
[features]
surrealdb = ["dep:surrealdb-types"]When the feature is off, our domain is just Rust: no DB types, no DB dependency, no DB opinions. When it’s on, every entity picks up a clean SurrealValue impl and the repositories in scylla-core know how to persist it. The coupling, when we accept it, is to the database we actually use, not to a wire format that happens to be JSON.
Derive vs hand-written
For simple value objects (Username, OrganizationName, and friends), we use #[derive(SurrealValue)] and get the impl for free. For the generated ID types, we hand-write it so kind_of() carries the table name. Both paths coexist without friction.
Closing thoughts
We’re proud of where this landed. There’s obviously room to improve, and we’re working on it. We’ll be happy to cover how it evolves in future devlogs!
Stay tuned!
Keep reading
© 2026 Scylla · Released under the Apache-2.0 license · Legal notice