⚠️ EXPERIMENTAL - NOT READY FOR USEThis project is in early development. APIs are unstable and subject to breaking changes. The library is not yet published to crates.io, and referenced packages/examples may be incomplete or non-existent.
Do not use this in production or depend on it for any real projects.
A type-safe event sourcing library implementing multi-stream event sourcing with dynamic consistency boundaries - commands that can atomically read from and write to multiple event streams.
Traditional event sourcing forces you into rigid aggregate boundaries. EventCore breaks free with:
- Multi-stream commands: Read and write multiple streams atomically
- Type-safe by design: Illegal states are unrepresentable
- Dynamic stream discovery: Commands can discover streams at runtime
- Zero boilerplate: No aggregate classes, just commands and events
Note: The following is a design vision, not current reality. Packages are not yet published.
# Cargo.toml (EXAMPLE - not yet available on crates.io)
[dependencies]
eventcore = { version = "0.1", features = ["postgres"] } # includes macros by default// Command macro is re-exported from eventcore (enabled by default)
use eventcore::{Command, require, StreamId};
use eventcore_postgres::PostgresEventStore;
#[derive(Command)]
struct TransferMoney {
#[stream]
from_account: StreamId,
#[stream]
to_account: StreamId,
amount: Money,
}
#[async_trait]
impl CommandLogic for TransferMoney {
type State = AccountBalances;
type Event = BankingEvent;
// type StreamSet is auto-generated by #[derive(Command)] ✅
fn apply(&self, state: &mut Self::State, event: &StoredEvent<Self::Event>) {
match &event.payload {
BankingEvent::MoneyTransferred { from, to, amount } => {
state.debit(from, *amount);
state.credit(to, *amount);
}
}
}
async fn handle(
&self,
read_streams: ReadStreams<Self::StreamSet>,
state: Self::State,
input: Self::Input,
_: &mut StreamResolver,
) -> CommandResult<Vec<StreamWrite<Self::StreamSet, Self::Event>>> {
require!(state.balance(&input.from_account) >= input.amount, "Insufficient funds");
let mut events = vec![];
emit!(events, &read_streams, input.from_account, BankingEvent::MoneyTransferred {
from: input.from_account.to_string(),
to: input.to_account.to_string(),
amount: input.amount,
});
Ok(events)
}
}
let store = PostgresEventStore::new(config).await?;
let executor = CommandExecutor::new(store);
let command = TransferMoney {
from_account: StreamId::try_new("account-alice")?,
to_account: StreamId::try_new("account-bob")?,
amount: Money::from_cents(10000)?,
};
let result = executor.execute(&command, command).await?;The #[derive(Command)] macro automatically generates boilerplate from #[stream] fields:
#[derive(Command)]
struct TransferMoney {
#[stream]
from_account: StreamId,
#[stream]
to_account: StreamId,
amount: Money,
}
// Automatically generates:
// - TransferMoneyStreamSet phantom type for compile-time stream safety
// - Helper method __derive_read_streams() for stream extraction
// - Enables type Input = Self pattern for simple commandsSome commands only learn about additional streams after inspecting state (e.g., an order references a payment-method stream). Implement StreamResolver<State> and return Some(self) from CommandLogic::stream_resolver() to opt in:
impl CommandLogic for ProcessPayment {
type State = CheckoutState;
type Event = CheckoutEvent;
fn stream_resolver(&self) -> Option<&dyn StreamResolver<Self::State>> {
Some(self)
}
// apply + handle omitted
}
impl StreamResolver<CheckoutState> for ProcessPayment {
fn discover_related_streams(&self, state: &CheckoutState) -> Vec<StreamId> {
state.payment_method_stream.clone().into_iter().collect()
}
}The executor deduplicates IDs returned by discover_related_streams, reads each stream exactly once, and includes every visited stream in the same optimistic concurrency check as the statically declared streams.
Optimistic locking prevents conflicts automatically. Just execute your commands - version checking and retries are handled transparently.
eventcore/ # Core library - re-exports types, macros, and optional adapters
eventcore-types/ # Shared vocabulary - traits and types (StreamId, Event, EventStore)
eventcore-postgres/ # PostgreSQL adapter (enabled via feature flag)
eventcore-macros/ # Derive macros (re-exported by eventcore)
eventcore-testing/ # Contract test suite for backends
| Feature | Default | Description |
|---|---|---|
macros |
Yes | Re-exports #[derive(Command)] from eventcore-macros |
postgres |
No | Re-exports PostgresEventStore from eventcore-postgres |
# Default (includes macros)
eventcore = "0.1"
# With PostgreSQL adapter
eventcore = { version = "0.1", features = ["postgres"] }
# Without macros (rare - for minimal builds)
eventcore = { version = "0.1", default-features = false }See eventcore-examples/ for complete working examples:
- Banking: Account transfers with balance tracking
- E-commerce: Order workflow with inventory management
- Sagas: Order fulfillment with distributed transaction coordination
- Web Framework Integration: REST API with Axum (task management system)
- Core Library - Types, traits, and patterns
- PostgreSQL Adapter - Production event store
- Testing Guide - In-memory store for tests
- Examples - Complete applications
- Web API Examples - Integration with Axum web framework
- Deployment Strategies - Docker, Kubernetes, and cloud deployment
- Monitoring & Metrics - Observability and troubleshooting
- Production Checklist - Best practices for production
- Production Configuration Example - Best practices for production config
# Setup
nix develop # Enter dev environment
docker-compose up -d # Start PostgreSQL
# Test
cargo nextest run # Fast parallel tests
cargo test # Standard test runner
# Bench
cargo bench # Performance benchmarksEventCore follows strict type-driven development. See CLAUDE.md for our development philosophy.
Licensed under the MIT License. See LICENSE for details.