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
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
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
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;
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
| Mode | When 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::Sync | Cheap 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:
// EventServiceProviderapp.listen::<UserRegistered, SendWelcomeEmail>(Mode::from_config());app.listen::<UserRegistered, AuditUserSignup>(Mode::from_config());app.listen::<OrderShipped, NotifyShipping>(Mode::from_config());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 workerIn 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
- Looks up registered listeners by
TypeId::of::<E>() - For each entry:
Sync→ calls listener handle inline, awaits, logs errorsQueue { connection, queue }→ serializes the event to JSON, builds aJob { event_name, listener_type, payload }, callsqueue::push(connection, queue, &job)
- 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_jobstable. - Delayed dispatch —
dispatch_after(event, Duration::from_secs(60)). - Batched dispatch — fan out hundreds of similar jobs efficiently.