Skip to content

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.

Terminal window
cargo add nest-rs-events

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:

crates/features/src/notifications/events/listener.rs (abridged)
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] makes NotificationsListener a 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 enforces Clone + Send + 'static on it through the call site.

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:

src/notifications/events/listener.rs
#[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.

The payload is a plain Clone struct at the producer’s port (event.rs) — the same feature that emits it owns its definition:

crates/features/src/posts/event.rs (abridged)
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.

The emitter injects Arc<EventBus> like any other dep and calls emit once the work is done:

crates/features/src/posts/service.rs (abridged)
#[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.

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:

crates/features/src/notifications/events/module.rs
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):

apps/api/src/module.rs (abridged)
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:

Terminal window
DEBUG nest_rs::events: wired event listener listener="NotificationsListener::on_post_published"

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 no Result to propagate, no retry, no dead-letter queue, no global rollback. Side effects that can fail belong on the Queue.
  • No bridge to a transactionemit does 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.

  • 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 …Event naming; 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).
  • 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 an events/ adapter folder.

Built by YV17labs