This walkthrough builds a bank account with mnesis' kernel alone: no store, no codec, no async. You will define events, fold them into state, decide new events from commands, and rehydrate an aggregate by replaying its history. It is the whole event-sourcing loop in one file.
The complete, runnable version is examples/inmemory.
The kernel only — pure domain logic, no persistence:
[dependencies]
mnesis = { git = "https://github.com/devrandom-labs/mnesis", features = ["derive"] }An id is any type that is stable, hashable, and has a byte representation (the
store uses the bytes as a key; Display is for humans).
use std::fmt;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
struct AccountId(String);
impl fmt::Display for AccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<[u8]> for AccountId {
fn as_ref(&self) -> &[u8] { self.0.as_bytes() }
}Events are a plain enum. #[derive(DomainEvent)] gives each variant a stable
name() used on the wire.
use mnesis::*;
#[derive(Debug, Clone)]
struct AccountOpened { owner: String }
#[derive(Debug, Clone)]
struct MoneyDeposited { amount: u64 }
#[derive(Debug, Clone)]
struct MoneyWithdrawn { amount: u64 }
#[derive(Debug, Clone)]
struct AccountClosed;
#[derive(Debug, Clone, DomainEvent)]
enum AccountEvent {
Opened(AccountOpened),
Deposited(MoneyDeposited),
Withdrawn(MoneyWithdrawn),
Closed(AccountClosed),
}AggregateState::apply is a pure fold: current state + one event → next state.
This is the only place state changes.
#[derive(Default, Debug, Clone)]
struct AccountState { owner: String, balance: u64, is_open: bool }
impl AggregateState for AccountState {
type Event = AccountEvent;
fn initial() -> Self { Self::default() }
fn apply(mut self, event: &AccountEvent) -> Self {
match event {
AccountEvent::Opened(e) => { self.owner = e.owner.clone(); self.is_open = true; }
AccountEvent::Deposited(e) => { self.balance += e.amount; }
AccountEvent::Withdrawn(e) => { self.balance -= e.amount; }
AccountEvent::Closed(_) => { self.is_open = false; }
}
self
}
}#[mnesis::aggregate] turns a unit struct into the aggregate marker. A
Handle<C> impl is a pure decision function — it reads borrowed state,
validates invariants, and returns decided events. It never sees version or
identity: a decision depends only on domain state and the command.
#[derive(Debug, thiserror::Error)]
enum AccountError {
#[error("account already open")]
AlreadyOpen,
#[error("account is closed")]
Closed,
#[error("insufficient funds: have {balance}, need {amount}")]
InsufficientFunds { balance: u64, amount: u64 },
}
#[mnesis::aggregate(state = AccountState, error = AccountError, id = AccountId)]
struct BankAccount;
struct OpenAccount { owner: String }
struct Deposit { amount: u64 }
struct Withdraw { amount: u64 }
impl Handle<OpenAccount> for BankAccount {
fn handle(state: &AccountState, cmd: OpenAccount) -> Result<Events<AccountEvent>, AccountError> {
if state.is_open { return Err(AccountError::AlreadyOpen); }
Ok(events![AccountEvent::Opened(AccountOpened { owner: cmd.owner })])
}
}
impl Handle<Withdraw> for BankAccount {
fn handle(state: &AccountState, cmd: Withdraw) -> Result<Events<AccountEvent>, AccountError> {
if !state.is_open { return Err(AccountError::Closed); }
if state.balance < cmd.amount {
return Err(AccountError::InsufficientFunds { balance: state.balance, amount: cmd.amount });
}
Ok(events![AccountEvent::Withdrawn(MoneyWithdrawn { amount: cmd.amount })])
}
}AggregateRoot<BankAccount> holds state and version. You handle a command to
get decided events, persist them (here into a HashMap), then
commit_persisted to advance the version and fold the events in — atomically,
so state can never lag its version.
let mut alice = AggregateRoot::<BankAccount>::new(AccountId("alice-001".into()));
// Decide → get events (nothing has changed yet).
let decided = alice.handle(OpenAccount { owner: "Alice".into() })?;
// Persist those events to your store, then sync the in-memory root:
let next = alice.version().map_or(Version::INITIAL, |v| v.next().expect("no overflow"));
alice.commit_persisted(next, &decided);Rehydration is just replaying the stored events in version order:
let mut reloaded = AggregateRoot::<BankAccount>::new(id.clone());
for e in stored_events { // each is a VersionedEvent<AccountEvent>
reloaded.replay(e.version(), e.event())?;
}
// reloaded.state() now reflects the full history.That is the complete loop: decide (handle) → persist → fold
(commit_persisted), and rehydrate (replay) on load.
- Aggregates and Handle & Decide — the kernel model in depth.
- Repository — let
mnesis-storedo the persist-and-rehydrate loop for you against a real store. - Closing the Books — keep streams short so rehydration stays fast without snapshots.