Events
Events are the immutable facts of your system. This chapter covers how to define them, what data they carry, and how they flow through Umari.
Defining an event
An event pairs an event-type string with a typed payload and a list of domain-ID fields.
An event is a struct that derives Event, DomainIds, Serialize, and Deserialize:
use umari::prelude::*;
use serde::{Serialize, Deserialize};
use uuid::Uuid;
#[derive(Event, DomainIds, Serialize, Deserialize)]
#[event_type("project.created")]
pub struct ProjectCreated {
#[domain_id]
pub project_id: Uuid,
#[domain_id]
pub user_id: u64,
pub title: String,
pub duration_months: u32,
}
Event and DomainIds are independent derives: Event does not include DomainIds, so you need to add both. Clone and Debug are optional; add them if you want them on a particular event.
The #[event_type("project.created")] attribute sets the EVENT_TYPE constant. This string is what the event store uses to identify the event, what projectors and effects filter on, and what appears in the event_type field of StoredEvent. The attribute is optional; if omitted, EVENT_TYPE defaults to the struct name.
Naming convention: A common convention is dot-separated past-tense verb phrases like user.registered, project.created, order.paid, where the first segment is the domain entity and the second is the action. Umari doesn’t enforce this; use whatever naming scheme fits your project.
Domain IDs
Domain-ID fields become tags on the stored event. Commands query for events by these tags via DCB, and event sets (the queries used by folds, projectors, and effects) use them to declare what they want to see. Choose domain IDs carefully:
- What entity is this event “about”? That’s a domain ID.
- Is this just reference data? Not a domain ID.
Annotate fields with #[domain_id]:
#[derive(Event, DomainIds, Serialize, Deserialize)]
#[event_type("task.created")]
pub struct TaskCreated {
#[domain_id] pub task_id: Uuid, // ✓ identifies the task
#[domain_id] pub project_id: Uuid, // ✓ identifies the project
#[domain_id] pub user_id: u64, // ✓ identifies the user
pub title: String, // ✗ just data
pub description: String, // ✗ just data
}
Each domain ID becomes a tag like user_id:42. When a command queries for events with user_id=42, the event store returns only events carrying that tag.
Tag names are the field names. A tag is
<field>:<value>, so the field name is part of the contract: every module that queries an event must spell the field the same way. If you mix Rust and TypeScript modules over the same events, keep the field names identical (the Rust SDK can rename a tag to line up with a TypeScript field, or vice versa; see below).
Renaming domain IDs (Rust)
In Rust you can override the tag name, which is useful when the struct field name differs from the domain concept:
#[domain_id("project_id")]
pub project_project_id: Uuid,
This produces a tag like project_id:abc-def rather than project_project_id:abc-def. In TypeScript the tag is always the field name as declared in domainIds; rename the field itself to change the tag.
How domain IDs are collected
#[derive(DomainIds)] generates the domain_ids() method, which collects all #[domain_id] fields into a DomainIdBindings map used for querying. It’s a separate derive from Event: events need both, and so do other structs that participate in DCB queries (see Domain IDs).
Encryption scopes
Events that contain sensitive data (PII, access tokens, etc.) can be encrypted at rest. Encryption is keyed by a scope string you derive from the event.
Mark a single field with #[crypto_scope]:
#[derive(Event, DomainIds, Serialize, Deserialize)]
#[event_type("user.registered")]
pub struct UserRegistered {
#[domain_id]
#[crypto_scope]
pub user_id: u64,
pub email: String,
pub name: String,
}
The field marked #[crypto_scope] does not mean “encrypt this field.” Instead, its value (combined with the field name as field_name:value, e.g. user_id:42) becomes the scope string.
The scope string becomes a lookup key for an encryption key, and the runtime then encrypts the entire event payload with that key. The event envelope (id, position, tags, timestamps, etc.) is never encrypted.
- No scope (no
#[crypto_scope]field / nocryptoScope) → the event is stored in plaintext. - A scope string → the whole payload is encrypted under a per-scope AES-256-GCM key. Each unique scope value (e.g. each
user_id) gets its own key.
Encryption is transparent:
- Writing: the runtime serializes the event, fetches/creates the key for the scope, and encrypts the payload before appending.
- Reading: the runtime decrypts the payload before passing it to folds, projectors, and effects.
- Key deletion: deleting a scope’s key (crypto-shredding) makes all events for that scope permanently unreadable; they appear as
Value::Nulland are skipped.
The dedicated Encryption & Crypto-Shredding chapter covers key management and shredding in full (still in draft).
What an event definition carries
The Event derive macro generates an implementation of the Event trait:
pub trait Event: DomainIds + Serialize + DeserializeOwned + Sized {
const EVENT_TYPE: &'static str;
fn encryption_scope(&self) -> Option<String> {
None // Overridden if any field has #[crypto_scope]
}
}
You rarely need to know this; the derive macro handles everything. But it’s useful to understand when debugging.
The StoredEvent envelope
When you receive an event in a fold, projector, or effect, it arrives wrapped in an envelope carrying tracing and routing metadata alongside your typed data:
pub struct StoredEvent<T> {
pub id: Uuid,
pub position: u64,
pub event_type: String,
pub tags: Vec<String>, // ["user_id:42", "project_id:abc"]
pub timestamp: DateTime<Utc>,
pub correlation_id: Uuid,
pub causation_id: Uuid,
pub triggering_event_id: Option<Uuid>,
pub idempotency_key: Option<Uuid>,
pub encryption_scope: Option<String>,
pub encryption_key_id: Option<Uuid>,
pub data: T, // Your typed event data
}
The data field contains your typed event payload. The envelope fields carry tracing and routing metadata.
Event sets
An event set groups multiple event types together. Folds, projectors, and effects use one to declare which events they care about: the runtime turns it into the DCB query and the discriminated union you receive in handlers.
An event set is an enum (conventionally named Query) deriving EventSet:
#[derive(EventSet)]
enum Query {
UserRegistered(UserRegistered),
UserReactivated(UserReactivated),
}
The #[derive(EventSet)] macro generates the EventSet trait implementation:
pub trait EventSet: Sized {
type Item;
fn event_types() -> Vec<&'static str>; // ["user.registered", "user.reactivated"]
fn event_domain_ids() -> Vec<EventDomainId>; // Domain ID requirements per event type
fn from_event(event_type: &str, data: &Value)
-> Option<Result<Self::Item, SerializationError>>;
}
Subscribing to a single event
For folds that only care about one event type, use the built-in SingleEvent<E>:
impl Fold for UserExistsFold {
type Events = SingleEvent<UserRegistered>;
type State = bool;
fn apply(&self, exists: &mut bool, event: StoredEvent<UserRegistered>) {
*exists = true;
}
}
SingleEvent<E> is a built-in event set for a single event type. It’s the simplest way to subscribe to one event.
Scoping which events a fold sees
By default an event in a fold’s set is filtered using all the domain ID bindings the fold was given. Often you want to narrow that. For example, a fold that checks whether a task title is unique within a project should filter TaskCreated by project_id only, not by task_id (which would only ever return the single task you already know about).
The #[scope(...)] attribute on an EventSet variant controls which domain ID tags filter that event type:
#[derive(EventSet)]
pub enum TaskTitleEvents {
// Filter TaskCreated by project_id only, to see every task in the project
#[scope(project_id)]
TaskCreated(TaskCreated),
// Uses all domain ID bindings from the fold
TaskArchived(TaskArchived),
// Always match events where user_id = "system", regardless of bindings
#[scope(user_id = "system")]
SettingsChanged(SettingsChanged),
}
Three forms:
- No attribute: filtered using all domain ID bindings from the fold/command input
#[scope(field_name)]: filter only by the named domain ID, restricting scope to fewer IDs#[scope(field_name = "literal")]: hardcoded tag value, matching events with that fixed value
For projectors and effects (which have no fold bindings), only the hardcoded form is meaningful.
Naming conventions
- Event payload type:
PascalCasepast-tense verb phrase:TaskCreated,UserRegistered,ProjectArchived(astructin Rust, atype+ exporteddefineEventconst in TypeScript) - Event type string:
object.verbdot notation:"task.created","user.registered" - Rust event sets: an enum conventionally named
Query. TypeScript event sets: anevents: [...]array