Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ferrite

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.

What it gives you

  • Typed CQRS contracts. A command or query declares the type of its reply, so invoke hands 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 with Correlation, Headers or Attempt. 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.

Layout

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.

Samples

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 api

Going durable

use 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.

Status

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.

Building

cargo build --workspace --all-features
cargo test  --workspace --all-features
cargo clippy --workspace --all-targets --all-features

Tests 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 remove

Warnings are denied workspace wide, and unsafe is forbidden in every crate.

About

Messaging, mediator and CQRS framework for Rust in the spirit of WolverineFX. Handlers are plain async functions: typed contracts, cascading messages, Russian Doll middleware, a durable outbox and inbox, Postgres leader election, AMQP transport and HTTP endpoints.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages