Learn / Tutorials / First Steps with ArchitectureKit / Rust / Defining Events and State
Step 2 of 6

Defining Events and State

In this step, you define the events of a book, the state that is built from them, and register the schemas of the events with EventSourcingDB.

Defining Events#

An event describes something that has happened. In the library, a book goes through three of them: it is acquired, borrowed, and returned.

In ArchitectureKit, an event is a struct that derives Event, together with serde's Serialize and Deserialize, and names its event type in the attribute event. Create a file src/events.rs:

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

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

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

#[derive(Serialize, Deserialize, Event)]
#[event(type = "io.eventsourcingdb.library.book-returned")]
pub struct BookReturned {}

The struct becomes the data of the event, as serde writes it, so rename_all names the fields of BookBorrowed borrowedBy and borrowedUntil. Other parts of an event are taken from elsewhere: the subject, which names the book an event belongs to, comes from the command that causes it, and the source from the store that you create below. The event types are written as reverse domain names, as EventSourcingDB requires (see Event Types).

EventSourcingDB can check every event against a JSON schema of its type. The derive also derives that schema from the fields that serde writes: it describes exactly these fields, requires all of them, and allows no others. BookReturned carries no data, so its schema describes an empty object. It is written with braces, since serde writes a unit struct such as struct BookReturned; as null, which is no object, so the derive refuses it.

To constrain a value further than its Rust type does, for example to require the return date to be a date, give the value a type of its own that implements Schema by hand, or change the derived schema with schema_with (see Describing Events with Schemas). For details on events, see Defining Events.

Defining the State#

To decide whether a book can be borrowed, you need to know whether it exists and whether it is borrowed right now. That is the state of a book, and it is built from the book's events.

A state evolves by an enum of its events, with one variant per event type, which derives Events. Create a file src/book.rs with the enum and the state:

use architecturekit::{Events, State};

use crate::events::{BookAcquired, BookBorrowed, BookReturned};

#[derive(Events)]
pub enum BookEvent {
  Acquired(BookAcquired),
  Borrowed(BookBorrowed),
  Returned(BookReturned),
}

#[derive(Clone, Default)]
pub struct Book {
  pub is_acquired: bool,
  pub is_borrowed: bool,
}

impl State for Book {
  type Event = BookEvent;

  fn evolve(mut self, event: &BookEvent) -> Self {
    match event {
      BookEvent::Acquired(_) => self.is_acquired = true,
      BookEvent::Borrowed(_) => self.is_borrowed = true,
      BookEvent::Returned(_) => self.is_borrowed = false,
    }

    self
  }
}

A state implements State: Event names the enum of its events, and evolve says how an event changes the state. Every time the state is read, it starts from its Default, and evolve takes it one event further, event by event. Since evolve matches on the enum, the compiler checks that it handles every event type.

Note that the state holds only what decisions need: neither the title nor the author of a book decides whether it can be borrowed, so the state leaves them out. Listing books with their titles is a job for the reading side, which you build in Defining a View and a Query. For details, see Defining State.

Registering the Schemas#

EventSourcingDB checks events against a schema only once the schema is registered. The enum knows the schemas of all its events, and register_schemas registers them through the store, which reads events from and writes them to EventSourcingDB.

Create a file src/store.rs that connects to EventSourcingDB, creates the store, and registers the schemas:

use std::error::Error;

use architecturekit::{Events, Store, eventsourcingdb::Client};

use crate::book::BookEvent;

pub async fn new_store() -> Result<Store, Box<dyn Error>> {
  let client = Client::new("http://localhost:3000".parse()?, "secret");
  let store = Store::builder(client, "https://library.eventsourcingdb.io").build();

  store.register_schemas(BookEvent::schemas()).await?;

  Ok(store)
}

Client::new creates a client of the client SDK, from the address of EventSourcingDB and its API token. The second argument of Store::builder is the source of all events that the store writes (see Sources), and build creates the store. schemas, which the derive of Events provides, returns the schemas of all events of the enum, and register_schemas registers them with EventSourcingDB.

The modules become part of the library crate once src/lib.rs lists them. Create it:

pub mod book;
pub mod events;
pub mod store;

To try it, replace the content of src/main.rs:

use std::error::Error;

use library::store::new_store;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
  new_store().await?;

  println!("Registered the event schemas.");

  Ok(())
}

#[tokio::main] runs main on Tokio, so that it can wait for the store with .await. If new_store fails, main returns the error, and the program stops.

Run the program:

cargo run

The first time, Cargo downloads and compiles the dependencies, which takes a moment. After Cargo's messages about building and running it, the program prints:

Registered the event schemas.

Run it a second time. The output is the same, because register_schemas is meant to be called on every start: for an event type that EventSourcingDB knows already, it checks that the registered schema is exactly the one from the code.

To see what EventSourcingDB knows now, list its event types:

curl \
  -X QUERY \
  -H "authorization: Bearer secret" \
  http://localhost:3000/api/v2/data/read-event-types

The response contains the three event types, each with its schema, followed by an end item:

{"type":"eventType","data":{"type":"io.eventsourcingdb.library.book-acquired","hasEvents":false,"schema":{"additionalProperties":false,"properties":{"author":{"type":"string"},"isbn":{"type":"string"},"title":{"type":"string"}},"required":["title","author","isbn"],"type":"object"}}}
{"type":"eventType","data":{"type":"io.eventsourcingdb.library.book-borrowed","hasEvents":false,"schema":{"additionalProperties":false,"properties":{"borrowedBy":{"type":"string"},"borrowedUntil":{"type":"string"}},"required":["borrowedBy","borrowedUntil"],"type":"object"}}}
{"type":"eventType","data":{"type":"io.eventsourcingdb.library.book-returned","hasEvents":false,"schema":{"additionalProperties":false,"properties":{},"type":"object"}}}
{"type":"end","data":{}}

All three are phantoms, with hasEvents set to false, since no events of these types have been written yet (see A Word on Phantom Event Types).

Keeping Schemas Stable#

A registered schema can not change. Suppose you added a field pages to BookAcquired – its schema would then change, too, and the program would refuse to start. It prints the error that main returns, in the form Rust shows it for debugging, which names the error as permanent, since starting again would fail the same way:

Error: Permanent(Other(Failure { message: "the schema of \"io.eventsourcingdb.library.book-acquired\" differs from the registered one, which cannot change; introduce a new event type and an upcaster instead", cause: None }))

So leave the events as they are for this tutorial. To change the shape of an event in a real application, introduce a new event type, and translate the stored events of the old type with an upcaster (see Versioning Events). For details on schemas, see Registering Event Schemas.