Skip to content

Events

Oxide events follow Laravel’s shape — a struct represents the event, one or more listener types subscribe, and oxide::events::dispatch(event) fires every registered listener. Listeners can run synchronously (inline, in the dispatching task) or queued (pushed to Redis or any configured queue, processed later by oxide queue:work).

Define an event

src/app/events/user_registered.rs
use oxide::events::Event;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct UserRegistered {
pub user_id: i64,
pub email: String,
}
impl Event for UserRegistered {
const NAME: &'static str = "auth.user_registered";
}

Clone + Serialize + DeserializeOwned + Send + Sync + 'static is required — Serialize/Deserialize so the event can travel through a queue, Clone so a single dispatch can fan out to multiple listeners.

NAME is a stable string identifier, used for logging and as the routing key inside queue jobs. Pick a namespaced shape ("orders.shipped", "auth.user_registered").

Define a listener

src/app/listeners/send_welcome_email.rs
use oxide::async_trait::async_trait;
use oxide::events::Listener;
use oxide::injectable;
use crate::app::events::UserRegistered;
#[injectable]
pub struct SendWelcomeEmail;
#[async_trait]
impl Listener<UserRegistered> for SendWelcomeEmail {
async fn handle(&self, event: &UserRegistered) -> anyhow::Result<()> {
tracing::info!(user_id = event.user_id, email = %event.email, "send welcome email");
// ... actually send the email
Ok(())
}
}

#[injectable] auto-generates FromContainer so the listener can hold Arc<T> services — same pattern as controllers. For stateless listeners, the macro just emits Arc::new(Self) with no fields.

Register the pair

src/providers/event_service_provider.rs
use oxide::events::Mode;
use oxide::http::prelude::*;
use crate::app::events::UserRegistered;
use crate::app::listeners::{AuditUserSignup, SendWelcomeEmail};
pub struct EventServiceProvider;
impl ServiceProvider for EventServiceProvider {
fn register(&self, app: &Application) {
// sync — runs inline when dispatch() is awaited
app.listen::<UserRegistered, AuditUserSignup>(Mode::Sync);
// queued — pushed to redis, "default" queue
app.listen::<UserRegistered, SendWelcomeEmail>(Mode::queue("redis"));
// queued on a custom queue name
app.listen::<UserRegistered, SendWelcomeEmail>(
Mode::queue("redis").on_queue("emails"),
);
}
}

The same (event, listener) pair only runs once even if listen is called twice — registration is idempotent.

Dispatch

use oxide::events::dispatch;
dispatch(UserRegistered { user_id: 42, email: "[email protected]".into() }).await;

Sync listeners are awaited before dispatch returns. Queued listeners are pushed to their target queue and run later by a worker.

Errors from individual listeners are logged via tracing::error! and don’t propagate — one failing listener doesn’t block others.

Modes

ModeWhen to use
Mode::from_config()Recommended default. Reads queue.default (env-driven via QUEUE_CONNECTION) and picks Sync if the value is "sync", else queue(<that connection>). Lets one env var flip the whole app between sync (dev) and queued (prod).
Mode::SyncCheap operations, observers that must complete in-band (audit logging into the same DB transaction, etc.). Pinned to inline regardless of env.
Mode::queue("redis")Anything slow or external — emails, webhooks, third-party API calls. Pinned to queued, default queue name.
Mode::queue("redis").on_queue("emails")Custom queue. Pair with oxide queue:work --queue=emails to dedicate a worker.

You can register the same listener with different modes (e.g. one sync audit listener + one queued email listener for UserRegistered).

Env-driven everything

Use Mode::from_config() for every listener and the whole app obeys a single env var:

// EventServiceProvider
app.listen::<UserRegistered, SendWelcomeEmail>(Mode::from_config());
app.listen::<UserRegistered, AuditUserSignup>(Mode::from_config());
app.listen::<OrderShipped, NotifyShipping>(Mode::from_config());
Terminal window
QUEUE_CONNECTION=sync cargo run # all listeners inline (dev)
QUEUE_CONNECTION=redis cargo run # all listeners queued (prod)
QUEUE_CONNECTION=redis cargo run -- queue:work # consume them with the worker

In config/queue.toml (default = "${QUEUE_CONNECTION:-sync}").

Mode::default() is also implemented as from_config(), so Mode::default() and Mode::from_config() are interchangeable.

What dispatch does internally

  1. Looks up registered listeners by TypeId::of::<E>()
  2. For each entry:
    • Sync → calls listener handle inline, awaits, logs errors
    • Queue { connection, queue } → serializes the event to JSON, builds a Job { event_name, listener_type, payload }, calls queue::push(connection, queue, &job)
  3. Returns once every sync listener has finished and every queued job has been pushed

The queue worker side is in Queue.

Inspect what’s registered

use oxide::events::dispatcher::global;
let _ = global();
// Listener counts per event are not currently exposed publicly —
// add a diagnostic command if you need it.

Patterns

Audit events stay sync; side effects go queued

app.listen::<OrderShipped, AuditOrderShipped>(Mode::Sync);
app.listen::<OrderShipped, NotifyCustomer>(Mode::queue("redis"));
app.listen::<OrderShipped, UpdateInventoryStats>(Mode::queue("redis").on_queue("analytics"));

The audit row gets written before dispatch returns (so the controller’s response reflects it). The customer email and analytics work happen out-of-band.

One listener, many events

Implement Listener<E> once per event type — there’s no “match-any-event” listener.

#[async_trait] impl Listener<UserRegistered> for AuditEverything { ... }
#[async_trait] impl Listener<OrderShipped> for AuditEverything { ... }
app.listen::<UserRegistered, AuditEverything>(Mode::Sync);
app.listen::<OrderShipped, AuditEverything>(Mode::Sync);

Roadmap

The following land in a follow-up release:

  • Retry + dead-letter queue — failed jobs currently log and drop. Coming: exponential backoff + failed_jobs table.
  • Delayed dispatchdispatch_after(event, Duration::from_secs(60)).
  • Batched dispatch — fan out hundreds of similar jobs efficiently.