Events

Events

On this page

The dispatcher is Rainier's hook bus — the seam where something happens and something else reacts without either knowing about the other.

// Raise
Event::instance().dispatch(PostPublished { post }).await?;

// React
events.listen(|event: Arc<PostPublished>| async move {
    Queue::instance().dispatch(NotifyAuthor { post_id: event.post.id }).await?;
    Ok(())
});

Any type is an event

impl<T: Send + Sync + 'static> Event for T {}

No derive, no registration, no marker trait to implement. A struct is an event because you dispatched it:

#[derive(Debug, Clone)]
pub struct PostPublished {
    pub post: Post,
}

Keeping events next to the model they concern is a convention worth following — it puts the vocabulary of the domain in one file.

Listening

// Async — the usual form.
events.listen(|event: Arc<PostPublished>| async move {
    tracing::info!(id = event.post.id, "published");
    Ok(())
});

// Synchronous, for something trivial.
events.listen_sync(|event: Arc<PostPublished>| {
    metrics::increment("posts.published");
    Ok(())
});

// Ordered — higher priority runs first.
events.listen_with_priority(listener, 100);

// Every event, whatever its type: its name and a type-erased payload.
events.listen_any(|name: &'static str, _event: Arc<dyn Any + Send + Sync>| async move {
    tracing::trace!(event = name, "dispatched");
    Ok(())
});

A wildcard listener can Arc::downcast the payload when it knows the type, but most of them — logging, metrics, an audit trail — only need the name.

Listeners receive Arc<E>, so several can read the event without cloning it. That is also why a listener cannot mutate it — see model hooks for the same argument in full.

Queued listeners

An ordinary listener runs inside the dispatch, which means inside the request that dispatched it. A welcome email sent from a listener is 400ms of SMTP the person who just signed up waits for, and an SMTP server having a bad minute becomes a registration endpoint having a bad minute.

The answer is listen_queued::<E, J>() plus a FromEvent impl saying which job the event becomes:

use rainier_framework::{DispatcherExt, FromEvent};

events.listen_queued::<UserRegistered, SendWelcomeEmail>();

impl FromEvent<UserRegistered> for SendWelcomeEmail {
    fn from_event(event: &UserRegistered) -> Self {
        Self { user_id: event.user_id }
    }
}

The dispatch now does one queue write and returns. The work happens in a worker, with retries, backoff and a failed-jobs table — all of which the inline version has none of.

InlineQueued
Runsin the requestin a worker
On failurefails the dispatchretried, then recorded
Seesthe event itselfwhat FromEvent copied out
Right fora counter, a cache invalidationmail, webhooks, anything over a network

The job is built at dispatch, not at handle. from_event runs while the event is still in hand, and what it copies out is serialised into the payload. So take an id, not a model: by the time a worker picks the job up, minutes may have passed and the row may have changed. Re-reading it is the point.

The QueueManager is resolved from the container when the event fires, not when the listener is registered — listeners are registered while the application is still being built. Dispatching therefore fails with a clear error if no queue is bound, rather than silently doing nothing: a listener that quietly stopped queueing is a feature that quietly stopped working.

listen_queued_on::<E, J>(queue) takes the queue explicitly, for a test or an application that does not install the facades.

Where listeners are registered

// src/app/providers/mod.rs
impl EventServiceProvider {
    pub fn register_listeners(events: &Dispatcher) {
        events.listen(|event: Arc<PostPublished>| async move {
            Queue::instance().dispatch(NotifyAuthor { post_id: event.post.id }).await?;
            Ok(())
        });
    }
}
Rainier::new(".").with_events(EventServiceProvider::register_listeners)

Through the builder, not a provider's register. The dispatcher is bound into the container after listeners are added, so with_events runs at the right moment.

Subscribers

For a group of related listeners:

pub struct OrderSubscriber;

impl EventSubscriber for OrderSubscriber {
    fn subscribe(&self, events: &Dispatcher) {
        events.listen(|e: Arc<OrderPlaced>| async move { … });
        events.listen(|e: Arc<OrderShipped>| async move { … });
        events.listen(|e: Arc<OrderRefunded>| async move { … });
    }
}
events.subscribe(&OrderSubscriber);

One call registers every listener the subscriber declares — no string-keyed map in the middle.

Dispatching

events.dispatch(event).await?;              // Err from any listener propagates
events.dispatch_quietly(event).await;       // returns how many ran; errors logged
events.dispatch_as("legacy.name", event).await?;

The difference between the first two is the whole design question, and Rainier gives you both because the right answer depends on the event:

flowchart TD
    A["dispatch(event)"] --> B[listener 1]
    B -->|Ok| C[listener 2]
    B -->|Err| D[stop — the error propagates<br/>to the dispatcher's caller]
    C -->|Ok| E["Ok(())"]

    F["dispatch_quietly(event)"] --> G[listener 1]
    G --> H[listener 2]
    G -.->|Err| I[logged, and it carries on]
    H --> J[returns the count that ran]

    style D fill:#633,stroke:#a66,color:#fff
    style J fill:#353,stroke:#6a6,color:#fff

Use dispatch when a listener failing should fail the operation — that is what makes a Creating hook able to veto a write.

Use dispatch_quietly for notifications, where one broken listener should not take down the request that raised the event.

Introspection

events.has_listeners::<PostPublished>();
events.forget::<PostPublished>();
events.forget_all();

Testing

let events = Dispatcher::fake();
app.instance(events);

// … exercise the code …

Event::instance().assert_dispatched::<PostPublished>();
Event::instance().assert_dispatched_times::<PostPublished>(1);
Event::instance().assert_not_dispatched::<PostDeleted>();

let dispatched: Vec<Arc<PostPublished>> = Event::instance().dispatched::<PostPublished>();
let names = Event::instance().dispatched_names();

The fake records instead of calling listeners. Every assertion panics if you call it on a real dispatcher, rather than passing vacuously — a test that asserts assert_not_dispatched against a live dispatcher would otherwise pass for entirely the wrong reason.

dispatched::<E>() gives you the events themselves, so you can assert on their contents:

let published = Event::instance().dispatched::<PostPublished>();
assert_eq!(published[0].post.title, "Hello");

Events already in the framework

You can listen to these without raising them:

EventFrom
Creating<M>, Created<M>repository writes
Updating<M>, Updated<M>
Deleting<M>, Deleted<M>
MessageSending, MessageSentthe mailer
JobProcessing, JobProcessedthe worker
JobReleased, JobFailed
HttpRequestFailed, JobAttemptFailed, CommandFailed, …the reporter — one event per kind of failure; the full catalogue is there
ExceptionReportedthe reporter — every failure once, whatever its kind

That is enough to build an audit log, a metrics feed, or a webhook fan-out without touching the code that does the work.

When to reach for an event

The question is whether the two sides should know about each other.

Good: a controller publishes a post; a listener queues a notification. The controller does not care that notifications exist, and adding a second reaction does not touch it.

Reaching for a notification or a broadcast instead? Three different things that compose: an event is a fact with no recipient, a notification is a message to a person, and a broadcast is a push to whoever is connected. See the table.

Bad: a controller dispatches ChargeCard and needs to know whether it worked. That is a function call, and dressing it as an event only hides the dependency and loses the return value.

A rule that holds up: if the raiser needs the outcome, call the thing. If it genuinely does not, raise an event.

Up close

The same typed failure the event bus carries, as Lookout stored it: kind, status, message and report metadata, serialisable so it can be forwarded anywhere.
A per-process rate limiter and an in-process scheduler lock are reported as Configuration failures at boot, not just logged, so they surface next to request errors.
Publishing dispatches PostPublished; its listener queues a job, and every step is a span under the event, so you can see what one event set off.

This page is the framework’s docs/events.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.