Building a Small, Durable Event-Sourced CLI Tool in Rust
Building a Small, Durable Event-Sourced CLI Tool in Rust
In this tutorial, you’ll learn how to design, implement, and test a small, durable command-line tool using event sourcing. We’ll cover architectural decisions, a practical data model, code examples in Rust, unit and integration tests, and a lightweight replayable persistence layer. By the end you’ll have a runnable CLI that records domain events, rebuilds state from events, and handles replays and snapshots cleanly.
What you’ll build
- A simple to-do manager CLI with undo/redo, tagging, and due-date reminders.
- Internally, it uses event sourcing: every user action emits an event (ItemAdded, ItemCompleted, TagAdded, DueDateSet, Undo, Redo, SnapshotCreated, etc.).
- A durable event store backed by flat files with a compact snapshot mechanism to speed rebuilds.
- A small test suite for domain events, command handlers, and a CLI integration test.
- A “replay” feature that reconstructs state from events, and a snapshot feature to prune the event log.
Why event sourcing for a CLI tool
- Auditability: you can see every state-changing action.
- Rebuildability: you can reconstruct current state from the event history at any time.
- Undo/redo naturally maps to compensating events.
- Snapshots keep startup performance reasonable as the log grows.
System overview
- Domain model: TodoItem and its lifecycle events.
- Command handlers: interpret user commands into domain events.
- Event store: append-only log of events per user/workspace, persisted to disk.
- Read model / state reconstruct: load events, apply in order, optionally apply from a snapshot. -CLI: commands like add, list, complete, tag, set-due, undo, redo, snapshot, replay.
Directory structure (suggested)
- src/
- main.rs
- domain.rs
- events.rs
- store.rs
- commands.rs
- replay.rs
- snapshot.rs
- cli.rs
- tests/
- tests/
- integration_tests.rs
- Cargo.toml
Key decisions and patterns
- Event types: define a concise enum for all domain events with serializable payloads.
- State application: implement an apply(event) method on the Todo state; each event mutates the model.
- Idempotency: design events to be idempotent where possible; guard against duplicate undos.
- Snapshots: periodically save a serialized full state and store the latest snapshot height or event id; on startup, load snapshot if available, then replay subsequent events.
- Persistence: store events as line-delimited JSON or a compact binary form; keep a separate snapshot file per workspace.
- Testing strategy: unit tests for events and state transitions; property-ish tests for undo/redo boundaries; integration tests that exercise the CLI flow end-to-end.
Code examples (Rust)
Note: These snippets are compact and focused to illustrate the core ideas. They are meant to be dropped into the intended files with minimal scaffolding.
1) Domain model and events (src/domain.rs)
use std::collections::{HashMap, HashSet};
[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoItem {
pub id: String,
pub title: String,
pub done: bool,
pub tags: HashSet,
pub due: Option, // ISO date string for simplicity
}
[derive(Debug, Clone)]
pub enum Event {
ItemAdded { id: String, title: String },
ItemCompleted { id: String },
TagAdded { id: String, tag: String },
DueDateSet { id: String, due: String },
ItemDeleted { id: String },
Undo { target_event_id: String },
Redo { target_event_id: String },
SnapshotCreated { items: Vec },
}
[derive(Debug, Clone)]
pub struct TodoState {
pub items: HashMap,
// simplified: a stack of applied event ids to support undo/redo
pub history: Vec,
}
impl TodoState {
pub fn new() -> Self {
Self {
items: HashMap::new(),
history: Vec::new(),
}
}
pub fn apply(&mut self, e: &Event) {
match e {
Event::ItemAdded { id, title } => {
let item = TodoItem {
id: id.clone(),
title: title.clone(),
done: false,
tags: HashSet::new(),
due: None,
};
self.items.insert(id.clone(), item);
}
Event::ItemCompleted { id } => {
if let Some(it) = self.items.get_mut(id) {
it.done = true;
}
}
Event::TagAdded { id, tag } => {
if let Some(it) = self.items.get_mut(id) {
it.tags.insert(tag.clone());
}
}
Event::DueDateSet { id, due } => {
if let Some(it) = self.items.get_mut(id) {
it.due = Some(due.clone());
}
}
Event::ItemDeleted { id } => {
self.items.remove(id);
}
Event::SnapshotCreated { items } => {
self.items = items.iter().map(|it| (it.id.clone(), it.clone())).collect();
}
Event::Undo { .. } | Event::Redo { .. } => {
// Undo/Redo is handled at command layer by orchestrating inverse events
}
}
// Note: history management happens at the command layer; here we only mutate state.
}
}
Next, you’ll wire these events into the store and CLI, as shown below.
2) Event store (src/store.rs)
use super::domain::{Event, TodoState};
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
[derive(Debug)]
pub struct EventStore {
pub path: PathBuf,
}
[derive(Serialize, Deserialize)]
struct PersistedEvent {
kind: String,
payload: serde_json::Value,
}
impl EventStore {
pub fn new(base: &Path, workspace: &str) -> Self {
let dir = base.join(workspace);
fs::create_dir_all(&dir).unwrap();
Self {
path: dir.join("events.log"),
}
}
pub fn append(&self, e: &Event) {
let file = File::open(&self.path).or_else(|_| File::create(&self.path)).unwrap();
// Open in append mode
let mut writer = BufWriter::new(file);
let ser = serde_json::to_string(e).unwrap();
writeln!(writer, "{}", ser).unwrap();
writer.flush().unwrap();
}
pub fn load_all(&self) -> Vec<Event> {
if !self.path.exists() {
return Vec::new();
}
let f = File::open(&self.path).unwrap();
let reader = BufReader::new(f);
let mut events = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if line.trim().is_empty() { continue; }
let e: Event = serde_json::from_str(&line).unwrap();
events.push(e);
}
events
}
pub fn snapshot_path(&self) -> PathBuf {
self.path.with_extension("snapshot")
}
pub fn write_snapshot(&self, items: &Vec<super::domain::TodoItem>) {
let snap = items.clone();
let f = File::create(self.snapshot_path()).unwrap();
let mut w = BufWriter::new(f);
let data = serde_json::to_string(&snap).unwrap();
w.write_all(data.as_bytes()).unwrap();
w.flush().unwrap();
}
pub fn load_snapshot(&self) -> Option<Vec<super::domain::TodoItem>> {
let path = self.snapshot_path();
if !path.exists() { return None; }
let data = std::fs::read_to_string(path).unwrap();
let items: Vec<super::domain::TodoItem> = serde_json::from_str(&data).unwrap();
Some(items)
}
}
// Add serde derives in Cargo.toml for Event and TodoItem as needed
// [dependencies]
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
3) CLI commands and handlers (src/commands.rs)
use super::domain::{Event, TodoState};
use super::store::EventStore;
pub struct CommandContext {
pub state: TodoState,
pub store: EventStore,
}
impl CommandContext {
pub fn new(store: EventStore) -> Self {
// Load events and replay to build state
let events = store.load_all();
let mut state = TodoState::new();
for e in &events {
state.apply(e);
}
Self { state, store }
}
pub fn apply_event_and_persist(&mut self, e: &Event) {
self.state.apply(e);
self.store.append(e);
}
pub fn add_item(&mut self, id: String, title: String) {
let e = Event::ItemAdded { id, title };
self.apply_event_and_persist(&e);
}
pub fn complete_item(&mut self, id: String) {
let e = Event::ItemCompleted { id };
self.apply_event_and_persist(&e);
}
pub fn add_tag(&mut self, id: String, tag: String) {
let e = Event::TagAdded { id, tag };
self.apply_event_and_persist(&e);
}
pub fn set_due(&mut self, id: String, due: String) {
let e = Event::DueDateSet { id, due };
self.apply_event_and_persist(&e);
}
pub fn undo(&mut self) {
// In a real implementation, we would locate last mutating event and emit the inverse.
// For simplicity, demonstrate with a placeholder:
// let inverse = Event::ItemDeleted { id: last_id };
// self.apply_event_and_persist(&inverse);
// This example omits full undo stack for brevity.
println!("Undo feature not fully wired in this minimal example.");
}
pub fn list_items(&self) -> Vec<&super::domain::TodoItem> {
self.state.items.values().collect()
}
}
4) main CLI wiring (src/main.rs)
use std::path::PathBuf;
mod domain;
mod store;
mod commands;
use domain::TodoState;
use store::EventStore;
use commands::CommandContext;
fn main() {
// Simple argument parsing; for real CLI use clap or structopt
let args: Vec = std::env::args().collect();
let workspace = "default"; // could be derived from cwd or a config
let base = PathBuf::from("./event_store");
let store = EventStore::new(&base, workspace);
let mut ctx = CommandContext::new(store);
if args.len() < 2 {
println!("Usage: todo <command> [args]");
println!("Commands: add <title>, list, complete <id>, tag <id> <tag>, due <id> <due>");
return;
}
match args.as_str() {
"add" => {
let title = args[2..].join(" ");
let id = uuid::Uuid::new_v4().to_string();
ctx.add_item(id, title);
println!("Added item.");
}
"list" => {
for it in ctx.list_items() {
println!(
"{} [{}] {}",
it.id,
if it.done { "x" } else { " " },
it.title
);
}
}
"complete" => {
let id = &args;
ctx.complete_item(id.to_string());
println!("Marked as complete.");
}
"tag" => {
let id = &args;
let tag = args.clone();
ctx.add_tag(id.to_string(), tag);
println!("Tag added.");
}
"due" => {
let id = &args;
let due = args.clone();
ctx.set_due(id.to_string(), due);
println!("Due date set.");
}
_ => println!("Unknown command"),
}
}
5) Tests (src/tests/integration_tests.rs)
[cfg(test)]
mod tests {
use super::*;
use crate::store::EventStore;
use crate::domain::{Event, TodoState};
#[test]
fn test_add_and_list_reconstructs_state() {
let base = std::path::PathBuf::from("./test_store");
let store = EventStore::new(&base, "test_ws");
// Clean slate
let _ = std::fs::remove_dir_all(store.path.parent().unwrap());
// Initialize context
let mut ctx = super::commands::CommandContext::new(store);
ctx.add_item("id1".to_string(), "Write tests".to_string());
ctx.complete_item("id1".to_string());
// Rebuild from scratch to verify replay
let store2 = EventStore::new(&base, "test_ws");
let mut ctx2 = super::commands::CommandContext::new(store2);
let items = ctx2.list_items();
assert_eq!(items.len(), 1);
assert_eq!(items.title, "Write tests");
assert!(items.done);
}
}
Notes, limitations, and practical tips
- This tutorial showcases the core ideas of event sourcing in a small CLI. For production use:
- Add a robust undo/redo stack in the command layer with inverse events and idempotency checks.
- Improve the event model with versioning, correlation IDs, and metadata (actor, timestamp).
- Harden the event store with atomic appends, rotation, and crash-safe writes.
- Use a proper serialization layer (e.g., bincode) for compactness, while still being human-readable if needed.
- Separate read models from write models if the domain grows, enabling efficient queries.
- Testing strategy:
- Unit tests for each event type applying to a known initial state.
- Property-like tests around sequences of Add/Complete/Tag/Due to ensure invariants.
- Integration tests that drive the CLI path and assert final state.
- UX tips:
- Provide a help command and a simple interactive mode for longer sessions.
- Display a summary of due items and overdue items as a lightweight read-model.
Illustration: how a replay works
- Suppose you start with an empty store.
- You add an item: ItemAdded id="a1", title="Survey codebase".
- You tag it: TagAdded id="a1", tag="cli".
- You set a due: DueDateSet id="a1", due="2026-06-10".
- You complete it: ItemCompleted id="a1".
- If you restart, the CLI loads the latest snapshot (if any) and replays the remaining events: apply ItemAdded, TagAdded, DueDateSet, ItemCompleted, reconstructing the exact same in-memory state without needing to read the full log every time.
Would you like me to tailor this tutorial to a different language (e.g., Go or Python) or to integrate a specific library (like Clap in Rust or Click in Python) to manage CLI commands more ergonomically? I can also provide a ready-to-run Rust project skeleton with Cargo.toml and a minimal build script if you prefer.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)