DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Small, Durable Event-Sourced CLI Tool in Rust

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.
}
Enter fullscreen mode Exit fullscreen mode

}

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)
}
Enter fullscreen mode Exit fullscreen mode

}

// 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()
}
Enter fullscreen mode Exit fullscreen mode

}

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"),
}
Enter fullscreen mode Exit fullscreen mode

}

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);
}
Enter fullscreen mode Exit fullscreen mode

}

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)