Quickstart

Model a bank account end to end — events, state, commands, replay — with no persistence layer.

Quickstart

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.

1. Add the dependency

The kernel only — pure domain logic, no persistence:

toml
[dependencies]
mnesis = { git = "https://github.com/devrandom-labs/mnesis", features = ["derive"] }

2. Identify the aggregate

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).

rust
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() }
}

3. Define the events

Events are a plain enum. #[derive(DomainEvent)] gives each variant a stable name() used on the wire.

rust
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),
}

4. Fold events into state

AggregateState::apply is a pure fold: current state + one event → next state. This is the only place state changes.

rust
#[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
    }
}

5. Bind the aggregate and decide from commands

#[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.

rust
#[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 &lt; cmd.amount {
            return Err(AccountError::InsufficientFunds { balance: state.balance, amount: cmd.amount });
        }
        Ok(events![AccountEvent::Withdrawn(MoneyWithdrawn { amount: cmd.amount })])
    }
}

6. Drive it — decide, persist, rehydrate

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.

rust
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:

rust
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) → persistfold (commit_persisted), and rehydrate (replay) on load.

Where to next