Quickstart
Build an event-sourced blog with htmx comments — one plain model, one event-sourced aggregate, one projection, two views, and a background job.
1. Scaffold a new app
rustavel new blog
cd blog
cp .env.example .env
Developing against a local checkout instead of a published release? Use rustavel new blog --local.
2. A plain model: Comment
rustavel make:model Comment --migration
#[derive(Model, sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
#[model(table = "comments", timestamps)]
pub struct Comment {
#[model(primary_key, skip_on_insert)]
pub id: i64,
pub post_id: String,
pub body: String,
#[model(skip_on_insert)]
pub created_at: chrono::NaiveDateTime,
#[model(skip_on_insert)]
pub updated_at: chrono::NaiveDateTime,
}
3. An event-sourced aggregate: Post
rustavel make:aggregate Post
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PostEvent {
Created { title: String, body: String },
Edited { title: String, body: String },
}
#[derive(Debug, Default, Serialize, Deserialize, Aggregate)]
#[aggregate(stream = "post", event = "PostEvent")]
pub struct Post {
pub title: String,
pub body: String,
}
impl Post {
fn apply_event(&mut self, event: &PostEvent) {
match event {
PostEvent::Created { title, body } | PostEvent::Edited { title, body } => {
self.title = title.clone();
self.body = body.clone();
}
}
}
}
Add the one-time shared events table every aggregate uses:
rustavel make:migration create_events_table --create events
CREATE TABLE events (
event_id TEXT PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
version INTEGER NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
recorded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (aggregate_type, aggregate_id, version)
);
make:aggregate also laid down a snapshots migration for you, the first time it ran. Reads via event_store::load_aggregate use it automatically: every 100 events (configurable with snapshot_every = N, or opt out with snapshot_every = 0), the current state gets cached there so later reads only replay the tail instead of the whole history.
4. A projection: PostSummary
rustavel make:projection PostSummary --aggregate Post
impl Projector<Post> for PostSummaryProjector {
async fn project(&self, tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, envelope: &EventEnvelope<PostEvent>)
-> Result<(), OrmError>
{
match &envelope.event {
PostEvent::Created { title, body } | PostEvent::Edited { title, body } => {
sqlx::query(
"INSERT INTO post_summaries (id, title, body) VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET title = excluded.title, body = excluded.body",
).bind(envelope.aggregate_id.to_string()).bind(title).bind(body)
.execute(&mut **tx).await?;
}
}
Ok(())
}
}
5. A view: posts/show
rustavel make:view posts/show
{% extends "layout.html" %}
{% block content %}
<h1>{{ title }}</h1>
<p>{{ body }}</p>
<div id="comments">
{% for comment in comments %}{% include "comments/_item.html" %}{% endfor %}
</div>
<form hx-post="/posts/{{ post_id }}/comments" hx-target="#comments" hx-swap="beforeend">
<textarea name="body" required></textarea>
<button type="submit">Add comment</button>
</form>
{% endblock %}
6. A background job: notifying on new comments
rustavel make:job SendCommentNotification
#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = "default", retries = 3)]
pub struct SendCommentNotification {
pub post_id: String,
pub comment_id: i64,
}
impl SendCommentNotification {
async fn handle_job(&self, db: &Db) -> Result<(), QueueError> {
sqlx::query("INSERT INTO notifications (post_id, comment_id) VALUES (?, ?)")
.bind(&self.post_id).bind(self.comment_id)
.execute(db).await?;
Ok(())
}
}
Dispatch it from CommentController::store right after the comment is created — the response doesn't wait on it:
rustavel_queue::dispatch(&db, &SendCommentNotification {
post_id: comment.post_id.clone(),
comment_id: comment.id,
}).await?;
make:job laid down the one-time jobs/failed_jobs migration for you, same as make:aggregate does for snapshots. Register the job with a JobRegistry wherever the worker runs, and it'll be picked up, retried on failure, and moved to failed_jobs after MAX_ATTEMPTS.
7. Run it
rustavel migrate
rustavel serve
curl -X POST localhost:3000/posts \
-H 'content-type: application/json' \
-d '{"title":"Event sourcing in Rust","body":"Straight from the quickstart."}'
Open the returned id's page in a browser, submit the comment form, and watch devtools' network tab — the response is a small HTML fragment, not a full page. That's htmx doing the swap. In another terminal, rustavel queue:work --once drains the queued notification job.
What you just built
Full auditability
Every edit to Post is a row in events, never overwritten. Replay the stream any time.
Event sourcing, opt-in
Comment didn't need any of it — plain Active Record is still the default.
No JS build step
The comment form is server-rendered HTML. htmx reads two attributes and does the rest.
Deferred work, no broker
The notification job queues on SQLite and runs via queue:work — no Redis, no separate service.
The complete, tested version of this app lives in examples/blog in the rustavel repo.