Events
An event listener is a method on any #[injectable] provider, tagged
with #[on_event] inside a #[listeners] impl block. Importing
EventsModule in the app’s AppModule wires every discovered listener
from the fully-assembled container at bootstrap.
An event is any Clone + Send + 'static type. Dispatch is in-process and
awaited: bus.emit(event) clones the event for each listener registered
on that type, runs them in registration order, and returns once they all
complete. There is no broker, no retries — for cross-process or durable
work, push a job on the Queue instead.
Install
Section titled “Install”cargo add nest-rs-eventsA first listener
Section titled “A first listener”A regular #[injectable] service with one decorated method. The
#[listeners] attribute marks the impl block; #[on_event] marks the
method as a listener for the event type read from its second parameter.
use std::sync::Arc;
use nest_rs_core::injectable;use nest_rs_events::listeners;
use crate::points::{Ledger, PointsAwarded};
#[injectable]pub struct PointsListeners { #[inject] svc: Arc<Ledger>,}
#[listeners]impl PointsListeners { #[on_event] async fn on_awarded(&self, event: PointsAwarded) { self.svc.credit(event.user_id, event.amount).await; }}#[injectable]makesPointsListenersa regular DI provider.#[listeners]on the impl block orchestrates the per-method#[on_event]attributes and submits one listener per decorated method.#[on_event]takes no arguments — the event type is read from the method’s second parameter. The bus enforcesClone + Send + 'staticon it through the call site.
Several listeners on the same provider
Section titled “Several listeners on the same provider”The pattern the framework is built for: one #[injectable] declares the
deps once, multiple decorated methods share them. Different events, same
service:
#[injectable]pub struct PointsListeners { #[inject] svc: Arc<Ledger>, #[inject] mailer: Arc<Mailer>,}
#[listeners]impl PointsListeners { #[on_event] async fn on_awarded(&self, event: PointsAwarded) { self.svc.credit(event.user_id, event.amount).await; }
#[on_event] async fn on_redeemed(&self, event: PointsRedeemed) { self.svc.debit(event.user_id, event.amount).await; }
#[on_event] async fn notify_milestone(&self, event: PointsAwarded) { if event.amount >= 1000 { self.mailer.send_milestone(event.user_id).await; } }}Three inventory entries — PointsListeners::on_awarded,
PointsListeners::on_redeemed, PointsListeners::notify_milestone — all
pointing at the same PointsListeners instance. Same Arc<Ledger>, same
Arc<Mailer>, three subscriptions. Two methods can listen to the same
event type; both run, in registration order.
Define an event
Section titled “Define an event”use uuid::Uuid;
#[derive(Clone)]pub struct PointsAwarded { pub user_id: Uuid, pub amount: u32,}That is the whole event surface — any plain Clone + Send + 'static
type. No trait, no derive macro. Any listener method whose second
parameter is PointsAwarded subscribes to it.
Emit an event
Section titled “Emit an event”The emitter injects Arc<EventBus> like any other dep and calls emit:
use std::sync::Arc;use uuid::Uuid;
use nest_rs_core::injectable;use nest_rs_events::EventBus;
use super::event::PointsAwarded;
#[injectable]pub struct PointsService { #[inject] events: Arc<EventBus>,}
impl PointsService { pub async fn award(&self, user_id: Uuid, amount: u32) { self.events.emit(PointsAwarded { user_id, amount }).await; }}emit is awaited — when it returns, every listener has run. Emitting an
event with no subscriber is a no-op (not an error), so adding a listener
later does not require touching the emitter.
Wire it in
Section titled “Wire it in”The listener provider and the producer are providers like any other,
listed in the feature’s module. The app activates the bus by importing
EventsModule:
use nest_rs_core::module;
use crate::points::PointsModule;use super::listener::PointsListeners;
#[module(imports = [PointsModule], providers = [PointsListeners])]pub struct PointsEventModule;use nest_rs_core::module;use nest_rs_events::EventsModule;
use features::points::{PointsModule, PointsEventModule};
#[module(imports = [ EventsModule, PointsModule, PointsEventModule,])]pub struct ApiModule;The shape of dispatch
Section titled “The shape of dispatch”bus.emit(event) reads the listener list once (under an uncontended
RwLock — read-only after bootstrap), then awaits each listener in turn
with its own clone of the event. Three consequences worth naming:
- Order is deterministic — registration order is preservation order;
listeners are registered in the order their providers appear in
providers = [...], then in the order their methods appear in the#[listeners]impl block. - Failure is local — a listener is fire-and-forget. The method must
return
(); errors are the method’s responsibility. There is no retry, no dead-letter queue, no global rollback. - No bridge to a transaction —
emitdoes not enroll itself in the ambient executor’s transaction. If a listener must write to the same transaction as the emitter, call the listener directly from the service instead of going through the bus; emit it once the transaction commits for the fire-and-forget cases.
Module-gated, even when the crate is shared
Section titled “Module-gated, even when the crate is shared”A worker app that links features for the data layer but only imports
PointsModule keeps the listener inert: the inventory entry is
present in the binary but skipped at boot because the provider is not
reachable from the worker’s module tree. Same property as queue
processors and scheduled jobs.
Going further
Section titled “Going further”- Queue — durable, distributed, retried; the right tool when the work must outlive the request or run on another binary.
- Schedule — for recurring system work, no event needed.
- Providers — the
#[injectable]model#[listeners]builds on (same#[inject]fields, same boot-time access graph).
Reference
Section titled “Reference”crates/nest-rs-events/—EventBus,EventsModule,#[listeners],#[on_event].crates/nest-rs-events/tests/bus.rs— end-to-end test wiring a producer and a multi-method#[listeners]provider in one module.