Subscribe FR

Engineering · Apr 20, 2026

SurrealDB 3 & serialization

Elwann Guillemot · 3 min read

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:

  1. IDs were stringly-typed. Our UserId, AgentId, and so on were String wrappers that became JSON strings on the wire. The SDK couldn’t tell a user record ID from an organization record ID from a random "…".
  2. 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:

rust
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:

rust
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:

toml
[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