A type-safe event sourcing and CQRS library for Rust with PostgreSQL persistence.
- Strongly-typed aggregates and events - Define your domain model with Rust structs and enums
- Mandatory transactions for ACID guarantees
- Built-in idempotency prevents duplicate event processing
- Optimistic concurrency control detects conflicting modifications
- Transactional outbox pattern for reliable side effects
- Snapshot optimization for fast aggregate loading
- In-memory repository for testing and development
Define your domain aggregate and events:
use eventastic::aggregate::{Aggregate, Context, Root, SideEffect};
use eventastic::event::DomainEvent;
use eventastic::memory::InMemoryRepository;
use eventastic::repository::Repository;
#[derive(Clone, Debug)]
struct BankAccount {
id: String,
balance: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum AccountEvent {
Opened { event_id: String, account_id: String, initial_balance: i64 },
Deposited { event_id: String, amount: i64 },
Withdrawn { event_id: String, amount: i64 },
}
impl DomainEvent for AccountEvent {
type EventId = String;
fn id(&self) -> &Self::EventId {
match self {
AccountEvent::Opened { event_id, .. } => event_id,
AccountEvent::Deposited { event_id, .. } => event_id,
AccountEvent::Withdrawn { event_id, .. } => event_id,
}
}
}
// Define a no-op side effect type
#[derive(Clone, Debug, PartialEq, Eq)]
struct NoSideEffect;
impl SideEffect for NoSideEffect {
type SideEffectId = String;
fn id(&self) -> &Self::SideEffectId {
unreachable!("No side effects are produced")
}
}
impl Aggregate for BankAccount {
const SNAPSHOT_VERSION: u64 = 1;
type AggregateId = String;
type DomainEvent = AccountEvent;
type ApplyError = String;
type SideEffect = NoSideEffect;
fn aggregate_id(&self) -> &Self::AggregateId {
&self.id
}
fn apply_new(event: &Self::DomainEvent) -> Result<Self, Self::ApplyError> {
match event {
AccountEvent::Opened { account_id, initial_balance, .. } => {
Ok(BankAccount {
id: account_id.clone(),
balance: *initial_balance,
})
}
_ => Err("Account must be opened first".to_string()),
}
}
fn apply(&mut self, event: &Self::DomainEvent) -> Result<(), Self::ApplyError> {
match event {
AccountEvent::Opened { .. } => Err("Account already exists".to_string()),
AccountEvent::Deposited { amount, .. } => {
self.balance += amount;
Ok(())
}
AccountEvent::Withdrawn { amount, .. } => {
if self.balance >= *amount {
self.balance -= amount;
Ok(())
} else {
Err("Insufficient funds".to_string())
}
}
}
}
fn side_effects(&self, _event: &Self::DomainEvent) -> Option<Vec<Self::SideEffect>> {
None
}
}Use the aggregate with transactions:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let repository = InMemoryRepository::<BankAccount>::new();
// Create new account using the Root trait
let mut account: Context<BankAccount> = BankAccount::record_new(
AccountEvent::Opened {
event_id: "evt-1".to_string(),
account_id: "acc-123".to_string(),
initial_balance: 1000,
}
)?;
// Deposit money
account.record_that(AccountEvent::Deposited {
event_id: "evt-2".to_string(),
amount: 500,
})?;
// Save with transaction
let mut transaction = repository.begin_transaction().await?;
transaction.store(&mut account).await?;
transaction.commit()?;
// Load account
let loaded_account = repository.load(&"acc-123".to_string()).await?;
assert_eq!(loaded_account.state().balance, 1500);
Ok(())
}Eventastic is built around four core concepts:
- Aggregates - Domain entities that apply events to update their state
- Events - Immutable records of what happened in your domain
- Context - Wrapper that tracks aggregate state and uncommitted events
- Repository - Persistence layer with transactional guarantees
Unlike many event sourcing libraries, Eventastic requires transactions for all write operations. This provides:
- ACID compliance - All changes are atomic and consistent
- Idempotency - Duplicate events are detected and handled gracefully
- Concurrency safety - Optimistic locking prevents data races
- Side effect reliability - External operations are processed via outbox pattern
Using Rust provides compile-time guarantees:
- Events must implement required traits (DomainEvent, Clone, etc.)
- Aggregates must handle all event types in match statements
- Error handling is explicit with Result types
- No null pointer exceptions or runtime type errors
Eventastic includes features needed for production systems:
- Automatic snapshot creation and loading
- Comprehensive error types with structured information
- Transaction-based consistency guarantees
The library provides multiple repository implementations:
eventastic::memory::InMemoryRepository- For testing and developmenteventastic_postgres::PostgresRepository- For production PostgreSQL storage with:- Event and snapshot storage with versioning
- Full transaction support with optimistic concurrency control
- Optional encryption for sensitive data
- Database migrations support
eventastic_outbox_postgres::TableOutbox- Transactional outbox pattern for reliable side effect processing
See the examples/ directory for complete implementations:
- Bank - Full banking domain demonstrating:
- Account creation and management
- Transaction processing
- Side effects via outbox pattern
- Idempotency and concurrency handling