Messaging, mediator and CQRS framework for Rust, in the spirit of WolverineFX.
A handler is a plain async function. No base class, no trait to implement, no registration attribute. Ferrite reads the message off the first parameter, builds the rest from the host, and dispatches whatever the handler hands back.
This is the whole of a working application. Run it with
cargo run -p ferrite --example quickstart.
use std::sync::Mutex;
use ferrite::prelude::*;
/// A command declares the type of its reply, so the caller gets a real value back
#[derive(Debug, Clone, Command)]
#[ferrite(name = "todo.add.v1", reply = u64)]
struct AddTodo {
title: String,
}
/// An event goes to every handler that wants it, and never replies
#[derive(Debug, Clone, Event)]
#[ferrite(name = "todo.added.v1")]
struct TodoAdded {
id: u64,
title: String,
}
#[derive(Debug, thiserror::Error)]
#[error("a todo needs a title")]
struct TitleIsEmpty;
/// Whatever your application already has, registered as a service
#[derive(Debug, Default)]
struct TodoList {
items: Mutex<Vec<String>>,
}
/// The first parameter is the message, everything after it is asked for by type
///
/// Returning the event instead of publishing it keeps this function free of I/O, so a test
/// can call it and look at what came back
async fn add_a_todo(
command: AddTodo,
Data(todos): Data<TodoList>,
) -> Result<(u64, Publish<TodoAdded>), TitleIsEmpty> {
if command.title.trim().is_empty() {
return Err(TitleIsEmpty);
}
let mut items = todos.items.lock().expect("lock");
items.push(command.title.clone());
let id = items.len() as u64;
Ok((id, Publish(TodoAdded { id, title: command.title })))
}
/// Two handlers on one event: both run, and neither knows about the other
async fn write_to_the_log(event: TodoAdded) {
println!(" log: added #{} {}", event.id, event.title);
}
async fn update_the_count(_event: TodoAdded, Data(todos): Data<TodoList>) {
println!(" stats: {} todos so far", todos.items.lock().expect("lock").len());
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let host = Ferrite::builder()
.service(TodoList::default())
.handler(add_a_todo)
.handler(write_to_the_log)
.handler(update_the_count)
.build()?;
let id = host.bus().invoke(AddTodo { title: "buy milk".into() }).await?;
println!(" reply: the command answered with #{id}");
let refusal = host
.bus()
.invoke(AddTodo { title: " ".into() })
.await
.expect_err("a blank title is refused");
match &refusal {
FerriteError::HandlerFailed { source, .. } => println!(" error: {source}"),
other => println!(" error: {other}"),
}
// the event handlers are still running: waiting for them is how a test stays
// deterministic without sleeping
host.bus().wait_until_settled().await;
Ok(())
} reply: the command answered with #1
error: a todo needs a title
log: added #1 buy milk
stats: 1 todos so far
Three things worth noticing in that output.
The reply came first, before either subscriber ran. Announcing something is not asking for
something: the command had already succeeded and its caller had no reason to wait on the
audit log. wait_until_settled is how the example stays deterministic, and it is what a test
uses instead of sleeping.
Neither handler published anything. add_a_todo returned the event as part of its result
and the runtime dispatched it afterwards. That is what keeps handlers testable as plain
functions, and it is why a subscriber that fails cannot undo a write that already happened.
The domain error survived. TitleIsEmpty is your type, not a Ferrite type, and it comes
back out the other side intact. Over HTTP the same error becomes a 400, decided by your code
rather than by the framework.
- Typed CQRS contracts. A command or query declares the type of its reply, so
invokehands back a real value rather than something you downcast. - Cardinality checked at startup. A command with two handlers, or a required command with none, fails when the host is built. Not under load.
- Extractor based handlers. Ask for a service with
Data<T>, or for envelope metadata withCorrelation,HeadersorAttempt. Extraction happens before the body runs, so a missing dependency never leaves half finished work behind. - Cascading messages. Return the messages that should follow instead of publishing them yourself. Handlers stay free of I/O and assertable as plain functions.
- Russian Doll middleware. Each step wraps everything inside it, sees the call on the way in and the outcome on the way out, and can refuse to let the message through.
- Retry policies. Handler failures are retried with a backoff. Wiring mistakes are not, because repeating them only burns attempts.
- No serialization on the local path. An in-process message stays a live Rust value from the caller to the handler. Bytes only exist at the process boundary.
- Pluggable durability and transports. A transactional outbox, an inbox that recognises a redelivery, and leader election across hosts. Which backend does that is one line of configuration.
| Crate | What lives there |
|---|---|
ferrite |
The facade almost every application depends on |
ferrite-core |
Contracts: messages, envelopes, handlers, extractors, cascading, codecs |
ferrite-macros |
#[derive(Command)], #[derive(Query)], #[derive(Event)] |
ferrite-runtime |
Registry, middleware, dispatcher, routing, transports, durability |
ferrite-postgres |
Message store, inbox and outbox, leader election |
ferrite-amqp |
AMQP 0-9-1 transport, serving RabbitMQ and LavinMQ |
Documentation lives in docs/: architecture, the
concept guides including durability, and the
decision records that explain why the design looks the way it does.
cargo run -p ferrite --example quickstart # the snippet above, start to finish
cargo run -p sample-mediator-basics # commands and queries, nothing else
cargo run -p sample-events-and-cascading # events, cascade, middleware, retries
cargo run -p sample-todo-web-api # a REST API over SQLite and axum, no infra needed
cargo run -p sample-distributed-todo --bin worker # two processes over Postgres and AMQP
cargo run -p sample-distributed-todo --bin apiuse ferrite_amqp::AmqpTransport;
use ferrite_postgres::PostgresMessageStore;
let host = Ferrite::builder()
.handler(handle_create_todo)
.message_store(PostgresMessageStore::connect(&database_url).await?)
.transport(AmqpTransport::connect(&broker_url).await?)
.publish::<TodoCreated>().durably_to("amqp://exchange/todo-events")
.listen("amqp://queue/todo-events")
.build()?;
host.start().await?;Nothing in a handler changes. See durability for the outbox, the inbox and how leadership is decided.
Ferrite is being built in phases. What is here today is the in-process foundation, complete and tested end to end.
| Phase | Scope | State |
|---|---|---|
| 1 | Contracts, macros, dispatcher, middleware, cascading, retries | Done |
| 2 | Routing, transports, local queues, outbox, inbox, dead letters, agents | Done |
| 3 | Postgres message store with leader election | Done |
| 4 | AMQP transport for RabbitMQ and LavinMQ | Done |
| 5 | Scheduled delivery and the application samples, HTTP and all | Done |
| 6 | Sagas, circuit breaker, OpenTelemetry | Planned |
Anything still planned is deliberately absent rather than stubbed: nothing in the public API today is a placeholder.
cargo build --workspace --all-features
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-featuresTests that need Postgres or a broker skip when neither is running. To exercise them:
./scripts/test-infra.sh start
cargo test --workspace --all-features
./scripts/test-infra.sh removeWarnings are denied workspace wide, and unsafe is forbidden in every crate.