Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Evento
A collection of libraries and tools that help you build DDD, CQRS, and event sourcing applications in Rust.
- Event sourcing — state changes stored as immutable events, with optimistic concurrency and a complete audit trail
- CQRS — commands, projections (read models), and continuous subscriptions
- Macros —
#[evento::aggregate],#[evento::command],#[evento::projection],#[evento::snapshot],#[evento::handler],#[evento::subscription] - Compact storage — fast binary serialization with bitcode
One Executor trait, many backends:
| Backend | Feature | Crate | Notes |
|---|---|---|---|
| SQLite / PostgreSQL / MySQL | sqlite / postgres / mysql |
evento-sql | via sqlx, with built-in migrations |
| Fjall (embedded LSM-tree) | fjall |
evento-fjall | no external server; Fjall::temporary() for tests |
| Remote (client/server TCP) | remote |
evento-remote | serve any executor over framed TCP |
| Accord (consensus, alpha) | — | evento-accord | leaderless replicated store, strictly serializable (design) |
Installation
Evento 2.x is currently in alpha; pin the exact pre-release version (a bare "2"
does not resolve pre-releases):
[]
= { = "2.0.0-alpha.27", = ["sqlite"] }
= "0.6"
= "1"
= "0.3"
Swap sqlite for postgres, mysql, fjall, or remote as needed (see
Feature flags).
Evento reports itself through tracing, which does
nothing until the application installs a subscriber — so install one in main before
anything else:
The default level is info. Add features = ["env-filter"] and
.with_env_filter(..) when a dependency is chatty at that level — the embedded
Fjall store is, so every app under examples/ does exactly that.
Quick start
1. Define events with an aggregate enum
Each variant becomes an event struct with all required traits (bitcode serialization,
Aggregate, AggregateEvent):
The aggregate type defaults to "{package_name}/{enum_name}". Pin it (and event
names) so refactors never orphan stored events:
Additional derives are passed the same way and combine with name = in any
order. They land on every generated event struct, so putting an event on the
wire as JSON needs no hand-written DTO:
Each variant is also collected into a sibling {Enum}Event enum, so a stored
event can be matched exhaustively on the way back out — to SSE, webhooks, an
outbox table or an audit log — instead of laddering over event.name:
# use Aggregate;
#
#
# let event = Event ;
match try_from?
# Ok::
Adding a variant now breaks every match site at compile time. Events decode
verbatim: upcast_to is not applied here, so an old stored event decodes to its
own variant. Derives passed to the attribute land on this enum too, so
serde::Serialize is enough to forward a whole decoded event.
2. Write events
create() starts a new aggregate; append(id) continues an existing one with
optimistic concurrency:
#
#
# async
3. Build read models with projections
Handlers are pure (event, &mut view) functions; load(id) replays the aggregate's
events through them:
use ;
#
#
// bitcode derives make the view snapshottable through the executor.
async
async
# async
4. Commands and the write gateway
The write side loads current state, guards invariants, then emits events through the
loaded projection's write() gateway — which continues the stream at the version the
load observed, so concurrent commands conflict instead of clobbering each other.
#[evento::command] generates routing-key variants from one method body:
use ;
#
#
// The write model: `id = id` implements ProjectionAggregate (enables `write()`),
// `snapshot(memory)` keeps a process-local materialized row per aggregate.
#
# async
#
# async
;
# async
See examples/bank for the full pattern with domain errors and ten
commands.
5. Snapshots
Loading replays an aggregate's events; snapshots cut that short. Three modes:
- Executor-backed (default): derive
bitcode::Encode/bitcode::Decodeon the projection (#[evento::projection(bitcode::Encode, bitcode::Decode)]) and the snapshot is persisted in the event store, keyed by(aggregate type, projection name, aggregate id)— so an aggregate can have any number of snapshotted views. The projection name defaults to"<module path>::<Struct>"; pin it withname = "..."so that renaming or moving the struct does not orphan its snapshots. #[evento::snapshot(memory)]: a process-local table keyed by aggregate id, with asnapshot_rows()accessor for reading materialized rows.#[evento::snapshot(none)]: opt out — always replay from scratch.
let rows = snapshot_rows.read.unwrap;
# drop;
// Executor-backed, with a name that survives refactors:
Changing the shape of an executor-backed projection needs a .revision(n) bump on its
Projection, so snapshots taken with the old shape are dropped instead of mis-decoded.
A stored snapshot that no longer decodes is treated as a miss and rebuilt from events.
6. Subscriptions
Process events continuously (side effects allowed), with cursor tracking, retries, and graceful shutdown:
use ;
#
#
async
# async
By default a subscription stops on the first handler error —
.continue_on_error() is opt-in — and a stopped worker never processes another event.
The handle is how you find out: stopped() resolves with a
StopReason
(Failed, LostOwnership, Shutdown, StoppedByHandler, Panicked), stop_reason()
is the non-blocking peek, and stop() signals a worker held behind an Arc. Combined with
the subscriber above, a broken handler is visible in minute one instead of hour two.
.data(v) stores v under its own type; a handler reads it back with
ctx.extract::<T>(), or ctx.try_extract::<T>()? to get an error instead of a panic
when it was never registered. Extraction clones, so the type should be Clone and cheap
to clone — wrap anything else in evento::context::Data and extract it as Data<T>.
Live bridges (SSE, WebSocket, fanout)
Forwarding events to a connection inverts every default above. History is noise to a client that just connected, the cursor is not worth a database write, and the handler — not a supervisor — is often the first thing to learn the consumer is gone. Three opt-ins cover it:
# use ;
#
#
async
# async
.ephemeral() keeps the cursor in memory, so the key stops being an identity: any number
of connections can share one, which is the whole point when there is a subscription per
connection. .start_from_latest() applies only when there is no cursor yet, so a durable
subscription still resumes on restart rather than jumping forward. ctx.stop() ends the
worker with StopReason::StoppedByHandler — a normal end, not a failure.
Reach past .live() for the combinations it does not cover: .ephemeral() on its own is a
throwaway in-memory index rebuilt from the whole stream on every boot, and
.start_from_latest() on its own is a durable subscription that skips history on its
first start and resumes normally after.
Each subscription is its own poller. One per connection earns its cost when each wants a
different slice (.aggregate::<A>(id), .routing_key(tenant)); for an unfiltered global
feed run one of them fanning out over a broadcast channel, as above.
examples/bank-axum-fjall serves a per-account SSE feed this way.
To drain currently-pending events once instead of running a background loop, use
run_once(&executor) (optionally after no_retry()). To keep a projection
auto-updated, use projection.subscription("key").start(&executor). Handlers for all
events of an aggregate without deserializing go through #[evento::subscription_all]
with RawEvent<A>, whose .decode() yields the same {Enum}Event when you do
want it typed.
7. Evolving events
A stored event never changes: bitcode is positional, so its layout is frozen once a database holds one. When an event needs a new shape, add a new variant and point the old one at it. Older stored events are then converted before handlers see them, so only the newest handler has to exist:
use ;
async
- It is declared once, on the aggregate. Every
ProjectionandSubscriptionBuilderthat registers a handler — or a.skip::<New>()— for the newer event picks it up;tombstone::<New>()andhas_event::<New>()follow the older names as well. - Chains work (
V1 -> V2 -> V3) and are folded into one decode and one encode. With handlers for bothV2andV3, aV1event goes to the nearest one. - A handler registered for the older event itself wins over the upcast, so consumers can migrate one at a time.
- The handler sees the newer event:
event.nameandevent.dataare the newer ones, everything else (id, version, timestamp, metadata) is the stored event's.#[evento::subscription_all]handlers keep receiving stored events as they are. - Snapshots hold folded state, not events: if the conversion yields different state
than the handler you removed did, bump the projection's
.revision(n).
Locking persisted shapes
Nothing in the compiler stops someone from editing a variant, or Money, or a
snapshotted view. evento-lock does: it scans the workspace with
syn, writes every persisted shape to events.lock, and fails a test when a line
that is already there changes.
event bank/BankAccount::MoneyDeposited { amount: i64, transaction_id: String, description: String }
type bank::value_object::AccountType enum { Checking, Savings, Business }
view bank::query::account_balance::AccountBalanceView rev=0 { balance: i64, …, cursor: String, aggregate_version: u16 }
eventlines (keyed by the stored name) andtypelines (everyEncodetype an event reaches, enums included: appending a variant changes the packed discriminant) are frozen. A new variant, companion event or upcast target is a new line.viewlines (projections snapshotted through the executor) may change when their projection's.revision(n)grows in the same commit.- Write-side
#[evento::snapshot(none | memory)]state and SQL read models are not persisted as bitcode, so they are not in the lock: they change freely.
// tests/events_lock.rs, with evento-lock as a dev-dependency
Run EVENTO_LOCK=update cargo test after adding events (it refuses breaking changes),
and EVENTO_LOCK=force only for shapes no deployed database has ever stored.
8. Reading events directly
Projections fold events into state. When you want the events themselves — to feed an SSE stream, a webhook, an outbox row or an audit log — read the stream directly:
#
#
# async
limit(n) caps the total number of events, not a page size, so a plain execute()
never silently truncates a stream. When you want to drive pagination yourself — a
GraphQL connection, an infinite scroll — page() returns one page plus the cursors to
continue from:
#
#
# async
Unlike a subscription, a reader with no routing_key(..) reads events under every
routing key; no_routing_key() narrows it to events committed without one. Use
read_raw(type, id) when the aggregate type is only known as a string, and drop to
executor.read(..) with hand-built EventFilters for queries spanning several
aggregates.
Wiring a backend
Fjall (embedded, zero setup)
#
SQLite (or PostgreSQL/MySQL) with migrations
use ;
use SqlitePoolOptions;
# async
Remote (client/server split)
Serve any executor over framed TCP; the client implements Executor, so commands,
projections, and subscriptions work unchanged across the network:
# async
Accord (replicated, alpha)
evento-accord replicates writes through the Accord consensus protocol
(Cassandra CEP-15): leaderless, strictly serializable, highly available, with any local
backend (Fjall/SQL) serving reads. See its README,
DESIGN.md, and OPERATIONS.md,
plus the bank-axum-accord 3-node demo.
Core API at a glance
| Concern | Entry point |
|---|---|
| Define events | #[evento::aggregate] enum |
| Decode a stored event | {Enum}Event::try_from(&event)? |
| Start a new aggregate | evento::create() → WriteBuilder |
| Append to an aggregate | evento::append(id) → WriteBuilder |
| Command with routing variants | #[evento::command] impl Command<E> |
| Read model | #[evento::projection] + #[evento::handler] fns |
| Emit events from loaded state | #[evento::projection(id = ...)] → view.write()? |
| Snapshot strategy | bitcode derives / #[evento::snapshot(memory)] / #[evento::snapshot(none)] |
| Load a read model | Projection::new::<A>().handler(..).load(id).execute(exec) |
| Co-keyed secondary aggregate | .load(id).aggregate::<Other>(other_id) |
| Read an aggregate's events | evento::read::<A>(id).execute(exec) |
| Read them typed | evento::read::<A>(id).decode(exec) |
| Paginate a read | .limit(n) / .after(cursor) / .page(exec) |
| Filter events when reading | EventFilter::by_type::<A>() / by_id::<A>(id) / by_event::<Ev>() / exact::<Ev>(id) |
| Continuous processing | SubscriptionBuilder::new(key)...start(exec) |
| One-shot processing | SubscriptionBuilder::new(key)...run_once(exec) |
| Keep a projection updated | projection.subscription(key).start(exec) |
| Fail on unhandled events | .strict() |
| Keep going after a handler error | .continue_on_error() |
| Notice a stopped subscription | subscription.stopped().await → StopReason |
| Live bridge (SSE/WebSocket) | .live(exec) = .ephemeral().start_from_latest().start(exec) |
| Skip history on a new subscription | .start_from_latest() |
| Stop a subscription from inside a handler | ctx.stop() |
Full macro reference: evento-macro/README.md.
Feature flags
macro(default) - Procedural macros for aggregates and handlerssql- Enable all SQL database backendssqlite/postgres/mysql- Individual SQL backends with migrationsfjall- Embedded key-value storage with Fjallremote- Client/server executor over framed TCPgroup- Multi-executor support for querying across databasesrw- Read-write split executor for CQRS patterns
Workspace crates
| Crate | Purpose |
|---|---|
| evento | Facade: re-exports core + feature-gated backends |
| evento-core | Executor trait, write path, projections, subscriptions |
| evento-macro | Procedural macros |
| evento-sql | SQLite/MySQL/PostgreSQL executor (sqlx) |
| evento-sql-migrator | Schema migrations for the SQL backend |
| evento-fjall | Embedded LSM-tree executor |
| evento-remote | Client/server executor over framed TCP |
| evento-accord | Accord consensus replicated executor (alpha) |
| evento-lock | events.lock: test that persisted shapes only grow |
Examples
Complete working examples in examples/:
quickstart- Smallest end-to-end run (Fjall):cargo run -p quickstartbank- Bank domain: aggregates, ten commands, projections, snapshotsbank-axum-sqlite- Axum + SQLite + migrations:cargo run -p bank-axum-sqlitebank-axum-fjall- Axum + embedded Fjall, plus a live SSE feed per account:cargo run -p bank-axum-fjallbank-axum-remote- Two-process client/server split:cargo run -p bank-axum-remote -- storethencargo run -p bank-axum-remotebank-axum-accord- 1- or 3-node Accord cluster:make accordormake accord.cluster
License
Licensed under the Apache License, Version 2.0.