Docs / ArchitectureKit / Rust / Guides / Describing Events with Schemas

Describing Events with Schemas

Every event type has a JSON schema, which the database checks every event of the type against, once the schema is registered (see Registering Event Schemas).

The Event derive derives it from the struct, so that it describes exactly what serde writes for the event:

  • A struct with named fields is an object with the fields serde writes: named as rename and rename_all say, and without the fields marked with skip or skip_serializing. Fields with skip_serializing_if are optional, all others are required, and no other fields are allowed. A newtype struct, and a struct marked with transparent, is described like the value it wraps.
  • A bool is a boolean, an i64 or any other integer type an integer, an f64 or an f32 a number, and a String, a str, or a char a string.
  • A Vec, a VecDeque, a slice, a HashSet, and a BTreeSet is an array, and an array [T; N] an array of exactly N items. A HashMap and a BTreeMap is an object whose values all have the same schema. A Vec<u8> is an array of integers, since that is what serde writes for it.
  • An Option may also be null, unless it is the type of an optional field, which serde leaves out instead of writing null. serde never writes a Vec or a HashMap as null, so a value that may be null is an Option, such as an Option<Vec<String>>. A Box, an Rc, an Arc, a Cow, and a reference are described like what they hold.
  • A DateTime of the chrono crate is a string in the date-time format, a serde_json::Number a number, and a serde_json::Value allows any value.

For BookBorrowed, this yields the following schema:

{
  "type": "object",
  "properties": {
    "borrowedBy": { "type": "string" },
    "borrowedUntil": { "type": "string" }
  },
  "required": ["borrowedBy", "borrowedUntil"],
  "additionalProperties": false
}

These rules do not change, since a registered schema can not change either.

Note that EventSourcingDB currently reads every number in the data of an event as a 64-bit floating-point number, which holds an integer exactly only if its magnitude is at most 2^53 - 1, which is 9007199254740991. A larger integer is stored changed, without an error: 9007199254740993 comes back as 9007199254740992, and a time in nanoseconds, as timestamp_nanos_opt of chrono returns it, loses its last digits. Close to the largest i64 or u64, the stored number may even lie beyond the range of the type, so that the event can not be read back any more, and load, execute, and a projection fail with a permanent failure. To keep a 64-bit integer exact, write it as a string, in a type of its own that serde writes as a string, and that describes itself as a string by implementing Schema by hand, as shown below.

To constrain a value further than its Rust type does, declare a type for it, and implement Schema for it by hand, with a schema function that returns the JSON schema of the type, as a Value of the serde_json crate, which you add with cargo add serde_json. Wherever a field has that type, the derived schema takes it over. For example, borrowedUntil accepts any string so far. To make sure that it is a date, BookBorrowed could use a Date type for the field:

use architecturekit::{Event, Schema};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Serialize, Deserialize)]
struct Date(String);

impl Schema for Date {
  fn schema() -> Value {
    json!({ "type": "string", "format": "date" })
  }
}

#[derive(Serialize, Deserialize, Event)]
#[event(type = "io.eventsourcingdb.library.book-borrowed")]
#[serde(rename_all = "camelCase")]
struct BookBorrowed {
  borrowed_by: String,
  borrowed_until: Date,
}

Note that the other examples in this documentation keep borrowed_until a String. With the Date type, they would convert between the two, as in Date(command.borrowed_until).

The schema of an enum, a union, a unit struct, a tuple struct with several fields, a struct marked with tag, content, into, or remote, and a field marked with flatten, serialize_with, or with can not be derived, since serde writes them differently than their types describe. Deriving it fails to compile, and the message says why. Implement Schema by hand for such a type, as for Date, for example for an enum of the formats of a book, which serde writes as the names of its variants. For an event without data, write a struct with braces, as in struct BookReturned {}, rather than a unit struct, which serde writes as null. A field whose type implements Schema neither way fails to compile as well, with the message that the type does not describe its JSON schema. A recursive type compiles, but panics once its schema is derived, such as by BookEvent::schemas(), and names the type.

Note that a field of type serde_json::Value allows any value, whatever it is meant to hold. To constrain such a field, give the event a schema of its own.

If an event needs a schema that its fields can not express, name a function with schema_with in the event attribute. The function gets the derived schema as a serde_json::Value, and returns the one that is registered, so that it can start from the derived schema. For example, to require at least one of two optional fields:

use architecturekit::Event;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Serialize, Deserialize, Event)]
#[event(type = "io.eventsourcingdb.library.book-corrected", schema_with = "at_least_one_field")]
struct BookCorrected {
  #[serde(skip_serializing_if = "Option::is_none")]
  title: Option<String>,
  #[serde(skip_serializing_if = "Option::is_none")]
  author: Option<String>,
}

fn at_least_one_field(mut derived: Value) -> Value {
  derived["minProperties"] = json!(1);
  derived
}

The function is named as a string, as serde names the functions it calls, and only once. A function that leaves the derived schema aside replaces it. An event whose schema can not be derived at all implements both Event and Schema by hand instead.

For a type within the data of an event, such as a nested struct, derive Schema, which follows the same rules. To say more about such a type than its fields can, name a function with #[architecturekit(schema_with = "...")] on the type, which works as schema_with does for an event. The attribute is named after the kit, so that it does not collide with the schema attribute of other derives, such as ToSchema of utoipa. The Schema derive reads #[architecturekit(...)] only on the type, as the Event derive reads #[event(...)], and each refuses its attribute on a field. A field that needs a schema of its own gets a type of its own instead, such as Date.

Note that a struct that writes two fields under the same name, for example because of rename, compiles, but panics once its schema is derived, and names the struct and the field.

Keeping Schemas in Files#

An event's own schema does not have to be written in Rust. To keep it in a JSON file instead, embed the file with include_str!, and read it in the function that schema_with names, which leaves the derived schema aside:

use architecturekit::Event;
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Serialize, Deserialize, Event)]
#[event(type = "io.eventsourcingdb.library.book-acquired", schema_with = "book_acquired_schema")]
struct BookAcquired {
  title: String,
  author: String,
  isbn: String,
}

fn book_acquired_schema(_derived: Value) -> Value {
  serde_json::from_str(include_str!("schemas/book-acquired.json"))
    .expect("schemas/book-acquired.json holds JSON")
}

include_str! embeds the file, relative to the source file that names it, when the application is compiled, so a missing file fails to compile. The function reads the JSON whenever the schema is derived, such as by BookEvent::schemas() when the application registers the schemas on start, so a file that does not contain valid JSON stops the application on start, before it writes anything.