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] provider 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 parameter. Here the
notifications slice reacts to a fact the posts slice publishes:
use nest_rs_core::injectable;use nest_rs_events::listeners;
use crate::posts::PostPublishedEvent;
#[injectable]#[derive(Default)]pub struct NotificationsListener;
#[listeners]impl NotificationsListener { #[on_event] async fn on_post_published(&self, event: PostPublishedEvent) { tracing::info!( target: "features::notifications", post_id = %event.post_id, org_id = %event.org_id, title = %event.title, "notifying subscribers of a published post", ); }}#[injectable]makesNotificationsListenera regular DI provider. It is a plain provider, not a service: it reacts to a fact, it is not an entity’s DB gateway.#[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 first parameter after&self. 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. Two methods can even
listen to the same event; both run, in registration order:
#[injectable]pub struct NotificationsListener { #[inject] mailer: Arc<Mailer>,}
#[listeners]impl NotificationsListener { #[on_event] async fn email_the_author(&self, event: PostPublishedEvent) { self.mailer.notify_published(event.post_id).await; }
#[on_event] async fn index_for_search(&self, event: PostPublishedEvent) { // reindex `event.post_id` for full-text search }}Two inventory entries — NotificationsListener::email_the_author and
NotificationsListener::index_for_search — both pointing at the same
instance. Same Arc<Mailer>, two subscriptions to PostPublishedEvent;
both run, in registration order.
Define an event
Section titled “Define an event”The payload is a plain Clone struct at the producer’s port
(event.rs) — the same feature that emits it owns its definition:
use uuid::Uuid;
#[derive(Clone)]pub struct PostPublishedEvent { pub post_id: Uuid, pub org_id: Uuid, pub title: String,}That is the whole event surface — any plain Clone + Send + 'static
type. No trait, no derive macro beyond Clone. Any listener method whose
parameter is PostPublishedEvent subscribes to it.
Emit an event
Section titled “Emit an event”The emitter injects Arc<EventBus> like any other dep and calls emit
once the work is done:
#[injectable]pub struct PostsService { #[inject] bus: Arc<EventBus>,}
impl PostsService { pub async fn create_in_org(&self, /* … */) -> Result<Post, ServiceError> { let model = todo!(); // insert through Repo, yielding the created row
// Publish the fact. `emit` is awaited: every listener has run by // the time it returns. self.bus .emit(PostPublishedEvent { post_id: model.id, org_id: model.org_id, title: model.title.clone(), }) .await; Ok(Post::from(&model)) }}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 emitting feature and the listener feature each list EventsModule in
their own module — the bus is a shared singleton, so a diamond import is
built once. The producer’s PostsModule lists imports = [EventsModule]
to inject Arc<EventBus>; the listener’s module does the same:
use nest_rs_core::module;use nest_rs_events::EventsModule;
use super::listener::NotificationsListener;
#[module( imports = [EventsModule], providers = [NotificationsListener],)]pub struct NotificationsEventsModule;The app composes the producer and the listener; EventsModule arrives
transitively through both (registration is idempotent):
use nest_rs_core::module;
use features::notifications::NotificationsEventsModule;use features::posts::PostsHttpModule;
#[module(imports = [ PostsHttpModule, NotificationsEventsModule,])]pub struct ApiModule;At bootstrap each wired listener logs one line at debug, target
nest_rs::events:
DEBUG nest_rs::events: wired event listener listener="NotificationsListener::on_post_published"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 returns
(); there is noResultto propagate, no retry, no dead-letter queue, no global rollback. Side effects that can fail belong on the Queue. - 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 it directly from the service instead of going through the bus; emit 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
PostsModule keeps NotificationsListener inert: the inventory entry is
present in the binary but skipped at boot (a warn on nest_rs::events)
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. A bus event
shares the past-tense
…Eventnaming; the difference is delivery (in-process best-effort vs durable cross-process). - 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/features/src/posts/event.rs+service.rs— the producer: payload at the port, emitted from the service.crates/features/src/notifications/events/listener.rs— the listener host in anevents/adapter folder.
Built by YV17labs