Your First EventCore Application
Let’s build a complete event-sourced application from scratch: a simple blog engine that demonstrates EventCore’s key concepts.
Project Setup
- Create a new Rust project
cargo new blog-engine
cd blog-engine
- Update Cargo.toml
[package]
name = "blog-engine"
version = "0.1.0"
edition = "2021"
[dependencies]
eventcore = "0.1"
eventcore-memory = "0.1"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
nutype = { version = "0.4", features = ["serde"] }
Step 1: Define Domain Types
Create src/types.rs
:
#![allow(unused)] fn main() { use eventcore::prelude::*; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // Use nutype for domain validation #[nutype( sanitize(trim), validate(not_empty, len_char_max = 100), derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRef) )] pub struct PostId(String); #[nutype( sanitize(trim), validate(not_empty, len_char_max = 200), derive(Debug, Clone, Serialize, Deserialize) )] pub struct PostTitle(String); #[nutype( sanitize(trim), validate(not_empty, len_char_max = 10000), derive(Debug, Clone, Serialize, Deserialize) )] pub struct PostContent(String); #[nutype( sanitize(trim), validate(not_empty, len_char_max = 100), derive(Debug, Clone, PartialEq, Serialize, Deserialize) )] pub struct AuthorId(String); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Comment { pub id: String, pub author: AuthorId, pub content: String, pub created_at: DateTime<Utc>, } }
Step 2: Define Events
Create src/events.rs
:
#![allow(unused)] fn main() { use crate::types::*; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BlogEvent { PostPublished { title: PostTitle, content: PostContent, author: AuthorId, published_at: DateTime<Utc>, }, PostUpdated { title: PostTitle, content: PostContent, updated_at: DateTime<Utc>, }, PostDeleted { deleted_at: DateTime<Utc>, }, CommentAdded { comment: Comment, }, CommentRemoved { comment_id: String, }, } }
Step 3: Define State
Create src/state.rs
:
#![allow(unused)] fn main() { use crate::types::*; use crate::events::BlogEvent; use chrono::{DateTime, Utc}; use std::collections::HashMap; #[derive(Debug, Clone, Default)] pub struct PostState { pub exists: bool, pub title: Option<PostTitle>, pub content: Option<PostContent>, pub author: Option<AuthorId>, pub published_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>, pub deleted_at: Option<DateTime<Utc>>, pub comments: HashMap<String, Comment>, } impl PostState { pub fn is_deleted(&self) -> bool { self.deleted_at.is_some() } pub fn apply_event(&mut self, event: &BlogEvent) { match event { BlogEvent::PostPublished { title, content, author, published_at, } => { self.exists = true; self.title = Some(title.clone()); self.content = Some(content.clone()); self.author = Some(author.clone()); self.published_at = Some(*published_at); } BlogEvent::PostUpdated { title, content, updated_at, } => { self.title = Some(title.clone()); self.content = Some(content.clone()); self.updated_at = Some(*updated_at); } BlogEvent::PostDeleted { deleted_at } => { self.deleted_at = Some(*deleted_at); } BlogEvent::CommentAdded { comment } => { self.comments.insert(comment.id.clone(), comment.clone()); } BlogEvent::CommentRemoved { comment_id } => { self.comments.remove(comment_id); } } } } }
Step 4: Implement Commands
Create src/commands.rs
:
#![allow(unused)] fn main() { use crate::events::BlogEvent; use crate::state::PostState; use crate::types::*; use chrono::Utc; use eventcore::prelude::*; // Publish a new blog post #[derive(Clone, Command)] #[command(event = "BlogEvent")] pub struct PublishPost { pub post_id: PostId, pub title: PostTitle, pub content: PostContent, pub author: AuthorId, } impl PublishPost { fn read_streams(&self) -> Vec<StreamId> { vec![StreamId::from(format!("post-{}", self.post_id.as_ref()))] } } #[async_trait] impl CommandLogic for PublishPost { type State = PostState; type Event = BlogEvent; fn apply(&self, state: &mut Self::State, event: &StoredEvent<Self::Event>) { state.apply_event(&event.event); } async fn handle( &self, _: ReadStreams<Self::StreamSet>, state: Self::State, _: &mut StreamResolver, ) -> CommandResult<Vec<StreamWrite<Self::StreamSet, Self::Event>>> { // Validate business rules require!(!state.exists, "Post already exists"); // Emit event Ok(vec![emit!( StreamId::from(format!("post-{}", self.post_id.as_ref())), BlogEvent::PostPublished { title: self.title.clone(), content: self.content.clone(), author: self.author.clone(), published_at: Utc::now(), } )]) } } // Add a comment to a post #[derive(Clone, Command)] #[command(event = "BlogEvent")] pub struct AddComment { pub post_id: PostId, pub comment_id: String, pub author: AuthorId, pub content: String, } impl AddComment { fn read_streams(&self) -> Vec<StreamId> { vec![StreamId::from(format!("post-{}", self.post_id.as_ref()))] } } #[async_trait] impl CommandLogic for AddComment { type State = PostState; type Event = BlogEvent; fn apply(&self, state: &mut Self::State, event: &StoredEvent<Self::Event>) { state.apply_event(&event.event); } async fn handle( &self, _: ReadStreams<Self::StreamSet>, state: Self::State, _: &mut StreamResolver, ) -> CommandResult<Vec<StreamWrite<Self::StreamSet, Self::Event>>> { // Validate require!(state.exists, "Post does not exist"); require!(!state.is_deleted(), "Cannot comment on deleted post"); require!(!state.comments.contains_key(&self.comment_id), "Comment ID already exists"); // Emit event Ok(vec![emit!( StreamId::from(format!("post-{}", self.post_id.as_ref())), BlogEvent::CommentAdded { comment: Comment { id: self.comment_id.clone(), author: self.author.clone(), content: self.content.clone(), created_at: Utc::now(), } } )]) } } }
Step 5: Create the Application
Update src/main.rs
:
mod commands; mod events; mod state; mod types; use commands::*; use eventcore::prelude::*; use eventcore_memory::InMemoryEventStore; use types::*; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Initialize the event store let event_store = InMemoryEventStore::new(); let executor = CommandExecutor::new(event_store); // Create author and post IDs let author = AuthorId::try_new("alice".to_string())?; let post_id = PostId::try_new("hello-eventcore".to_string())?; // Publish a blog post let publish_cmd = PublishPost { post_id: post_id.clone(), title: PostTitle::try_new("Hello EventCore!".to_string())?, content: PostContent::try_new( "This is my first event-sourced blog post!".to_string() )?, author: author.clone(), }; let result = executor.execute(publish_cmd).await?; println!("Post published with {} event(s)", result.events.len()); // Add a comment let comment_cmd = AddComment { post_id: post_id.clone(), comment_id: "comment-1".to_string(), author: AuthorId::try_new("bob".to_string())?, content: "Great post!".to_string(), }; let result = executor.execute(comment_cmd).await?; println!("Comment added!"); // Try to add duplicate comment (will fail) let duplicate_comment = AddComment { post_id, comment_id: "comment-1".to_string(), // Same ID! author: AuthorId::try_new("charlie".to_string())?, content: "Another comment".to_string(), }; match executor.execute(duplicate_comment).await { Ok(_) => println!("This shouldn't happen!"), Err(e) => println!("Expected error: {}", e), } Ok(()) }
Step 6: Run Your Application
cargo run
You should see:
Post published with 1 event(s)
Comment added!
Expected error: Comment ID already exists
What You’ve Learned
In this tutorial, you’ve implemented:
- Type-Safe Domain Modeling - Using
nutype
for validation - Event Sourcing Basics - Events as the source of truth
- Command Pattern - Encapsulating business operations
- Business Rule Validation - Enforcing invariants
- State Reconstruction - Building state from events
Next Steps
Enhance your blog engine with:
- Projections for querying posts by author or tag
- Multi-stream operations for author profiles
- Web API using Axum or Actix
- PostgreSQL backend for persistence
- Subscriptions for real-time updates
Continue learning: