Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b3692d8
Rewrite changes
jdon Sep 29, 2024
901a818
Remove box stream, move load into context and add tests
jdon Oct 9, 2024
a08a602
Add more test and remove unneeded funcs
jdon Oct 10, 2024
56b7dfa
Setup postgres for tests
jdon Oct 10, 2024
0e6927b
Fix CI tests
jdon Oct 10, 2024
251ac28
Update PostgreSQL service configuration in CI workflow
jdon May 9, 2025
4db01e0
Update PostgreSQL host in CI configuration to use localhost
jdon May 9, 2025
ad66c8a
Refactor account snapshot handling to use i64 for versioning, update …
jdon Jun 1, 2025
d956c4e
Fix lints
jdon Jun 1, 2025
7a8504f
Update license
jdon Jun 7, 2025
a718335
Add transactional outbox trait and example
jdon Jun 3, 2025
56c6cba
Rename outbox trait
jdon Jun 3, 2025
05dd5d1
Implement postgres outbox processing
jdon Jun 4, 2025
0e365fc
Fix lints
jdon Jun 7, 2025
f801265
Refactor outbox queries
jdon Jun 7, 2025
e719ebb
Merge pull request #12 from jdon/codex/move-transactional-outbox-queries
jdon Jun 7, 2025
3397fa1
Refactor outbox + remove domain event type
jdon Jun 8, 2025
1aee87b
Merge pull request #10 from jdon/codex/add-transactional-outbox-trait…
jdon Jun 8, 2025
94b188c
Update docs
jdon Jun 8, 2025
8ad056b
Refactor repository and transaction handling in eventastic_postgres
jdon Jul 3, 2025
49eeaee
Fix lint
jdon Jul 3, 2025
d81dd2d
Update docs and add unit tests
jdon Jul 9, 2025
4c11a55
Store multiple snapshot versions
jdon Jul 9, 2025
eb4b683
feat: add Pickle trait
aewakefield Jul 23, 2025
389fed5
Merge pull request #13 from jdon/pickles
jdon Jul 25, 2025
d9635e7
Actually stream events from postgres
jdon Jul 25, 2025
1c37bd2
feat: add `EncryptionProvider`
aewakefield Jul 24, 2025
9669394
chore: merge rewrite_traits into encryption
aewakefield Jul 30, 2025
2bb80a9
chore: fix pr comments
aewakefield Jul 31, 2025
ef45f72
wip: side effect trait must be generic
aewakefield Jul 31, 2025
c8f50bd
fix: finish updating generic
mapokko Jul 31, 2025
7009ced
fix: format
mapokko Jul 31, 2025
36f7e24
chore: tidy up
aewakefield Aug 1, 2025
e16f840
Remove table registry
jdon Aug 4, 2025
0502ff7
chore: simplify the error message
aewakefield Sep 11, 2025
9359b8e
Merge pull request #17 from jdon/simplify_error_message
jdon Sep 23, 2025
50772a6
Merge pull request #16 from jdon/side-effects-changes
jdon Sep 23, 2025
0bc5b55
Merge pull request #15 from jdon/side-effects
jdon Sep 23, 2025
23065a4
Merge pull request #14 from jdon/encryption
jdon Sep 23, 2025
896b3a6
Update readme
jdon Sep 23, 2025
2bd0893
Update futures-util
jdon Sep 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ jobs:
include:
- build: stable
toolchain: stable
# Service containers to run with `container-job`
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres:latest
# Provide the password for postgres
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
POSTGRES_DB: postgres
# Map port 5432 on the container to 5432 on the host
ports:
- 5432:5432
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3

Expand All @@ -52,6 +72,13 @@ jobs:
with:
command: test
args: --workspace --all-features
env:
# Use localhost instead of postgres hostname
POSTGRES_HOST: localhost
# Add database connection details
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres

formatting:
name: Rustfmt Check
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
**/target
Cargo.lock
.env
.env
.idea
15 changes: 12 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
[workspace]
resolver = "2"
members = ["eventastic", "eventastic_postgres", "examples/*"]
members = [
"eventastic",
"eventastic_outbox_postgres",
"eventastic_postgres",
"examples/*",
]

[workspace.package]
license = "MIT"
license-file = "LICENSE"

[workspace.dependencies]
# Eventastic dependencies
Expand All @@ -10,6 +19,7 @@ serde = { version = "1", features = ["derive"] }
thiserror = "1"

# Eventastic postgres dependencies
async-stream = "0.3.6"
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls",
"postgres",
Expand All @@ -22,5 +32,4 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = "0.4"
serde_json = "1"
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
anyhow = "1"
futures-util = "0.3.31"
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2019 Danilo Cianfrone
Copyright (c) 2025 Jonathan Donaldson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
237 changes: 168 additions & 69 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,100 +1,199 @@
# Eventastic

This is an opinionated fork of [Eventually-rs](https://github.com/get-eventually/eventually-rs).
A type-safe event sourcing and CQRS library for Rust with PostgreSQL persistence.

Eventastic enforces the use of transactions, handles idempotency and removes command handling abstractions.
## Features

## Examples
See full examples in [examples/bank](https://github.com/jdon/eventastic/blob/main/examples/bank/src/main.rs)
- **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

## Quick Start

Define your domain aggregate and events:

```rust
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// Setup postgres repo
let repository = get_repository().await;
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 },
}

// Run our side effects handler in a background task
tokio::spawn(async {
let repository = get_repository().await;
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,
}
}
}

let _ = repository
.start_outbox(SideEffectContext {}, std::time::Duration::from_secs(5))
.await;
});
// Define a no-op side effect type
#[derive(Clone, Debug, PartialEq, Eq)]
struct NoSideEffect;

// Start transaction
let mut transaction = repository.begin_transaction().await?;
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:

let account_id = Uuid::new_v4();
```rust
#[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()?;

let event_id = Uuid::new_v4();
// Load account
let loaded_account = repository.load(&"acc-123".to_string()).await?;
assert_eq!(loaded_account.state().balance, 1500);

let add_event_id = Uuid::new_v4();
Ok(())
}
```

// Open a bank account
let event = AccountEvent::Open {
event_id,
account_id,
starting_balance: 21,
email: "user@example.com".into(),
};
## Architecture

let mut account = Account::record_new(event)?;
Eventastic is built around four core concepts:

// Add funds to newly created account
let add_event = AccountEvent::Add {
event_id: add_event_id,
amount: 324,
};
- **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

// Record add fund events.
// Record takes in the transaction, as it does idempotency checks with the db.
account
.record_that(&mut transaction, add_event.clone())
.await?;
## Why Eventastic?

// Save uncommitted events and side effects in the db.
transaction.store(&mut account).await?;
### Transaction-First Design

// Commit the transaction
transaction.commit().await?;
Unlike many event sourcing libraries, Eventastic requires transactions for all write operations. This provides:

// Get the aggregate from the db
let mut transaction = repository.begin_transaction().await?;
- **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

let mut account: Context<Account> = transaction.get(&account_id).await?;
### Rust Benefits

// Check our balance is correct
assert_eq!(account.state().balance, 345);
Using Rust provides compile-time guarantees:

// Trying to apply the same event id but with different content gives us an IdempotencyError
let changed_add_event = AccountEvent::Add {
event_id: add_event_id,
amount: 123,
};
- 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

let err = account
.record_that(&mut transaction, changed_add_event)
.await
.expect_err("failed to get error");
### Production Ready

assert!(matches!(err, RecordError::IdempotencyError(_, _)));
Eventastic includes features needed for production systems:

// Applying the already applied event, will be ignored and return Ok
account.record_that(&mut transaction, add_event).await?;
- Automatic snapshot creation and loading
- Comprehensive error types with structured information
- Transaction-based consistency guarantees

transaction.commit().await?;
## Persistence

let mut transaction = repository.begin_transaction().await?;
The library provides multiple repository implementations:

let account: Context<Account> = transaction.get(&account_id).await?;
- `eventastic::memory::InMemoryRepository` - For testing and development
- `eventastic_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

// Balance hasn't changed since the event wasn't actually applied
assert_eq!(account.state().balance, 345);
## Examples

println!("Got account {account:?}");
See the `examples/` directory for complete implementations:

tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(())
}
```
- **Bank** - Full banking domain demonstrating:
- Account creation and management
- Transaction processing
- Side effects via outbox pattern
- Idempotency and concurrency handling
8 changes: 5 additions & 3 deletions eventastic/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "eventastic"
version = "0.4.0"
edition = "2021"
version = "0.5.0"
edition = "2024"
license = "MIT"
readme = "../README.md"
repository = "https://github.com/jdon/eventastic"
Expand All @@ -17,5 +17,7 @@ keywords = ["architecture", "ddd", "event-sourcing", "cqrs", "es"]
[dependencies]
async-trait = { workspace = true }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }

[dev-dependencies]
tokio = { version = "1.0", features = ["full"] }
Loading
Loading