Events
@blixis-io/events is a small in-process publish/subscribe bus for domain events — "post.created", "user.invited", whatever your app’s own event names are. It has nothing to do with HTTP: it depends only on @blixis-io/core and @blixis-io/di, the same category as @blixis-io/logging and @blixis-io/config, so it’s usable in any app, HTTP or not.
The shape
Section titled “The shape”Same factory-closure pattern as @blixis-io/config’s defineConfigModule and @blixis-io/tenancy’s defineTenancyModule — your app’s event-name-to-payload map is a generic parameter fixed once per app, not baked into the package:
type AppEvents = { "post.created": { postId: string; title: string }; "post.deleted": { postId: string };};
export const { EventsModule, EVENT_BUS } = defineEventsModule<AppEvents>();Use a type alias, not an interface, for the event map. An interface doesn’t satisfy defineEventsModule’s Record<string, unknown> generic constraint — TypeScript doesn’t give interfaces an implicit index signature even when every key is a string literal — so defineEventsModule<AppEvents>() fails to typecheck if AppEvents is declared with interface.
Wiring it in
Section titled “Wiring it in”@Module({ imports: [EventsModule.forRoot({ global: true })] })class AppModule {}global defaults to false. Pass true to make EVENT_BUS resolvable from any module without each one importing EventsModule directly — reasonable for an app-wide event bus, since most modules that emit or listen for domain events aren’t otherwise related to each other.
Emitting and listening
Section titled “Emitting and listening”@Injectable()class PostsService { constructor(@Inject(EVENT_BUS) private readonly events: EventBus<AppEvents>) {}
async create(input: CreatePostInput) { const post = await this.repo.insert(input); await this.events.emit("post.created", { postId: post.id, title: post.title }); return post; }}@Injectable()class SearchIndexer { constructor(@Inject(EVENT_BUS) events: EventBus<AppEvents>) { events.on("post.created", async (payload) => { await this.index(payload.postId); }); }}on() returns a function that unsubscribes just that one handler, leaving any others registered for the same event type intact.
@OnEvent: declare the handler instead
Section titled “@OnEvent: declare the handler instead”The factory also returns an OnEvent decorator typed to your event map. Put it on a method and the framework subscribes it for you, so a listener no longer needs the bus injected or a constructor:
export const { EventsModule, EVENT_BUS, OnEvent } = defineEventsModule<AppEvents>();@Injectable()class SearchIndexer { constructor(private readonly search: SearchClient) {}
@OnEvent("post.created") async index(payload: AppEvents["post.created"]): Promise<void> { await this.search.add(payload.postId); }}The compiler checks the method: a parameter that doesn’t match the event’s payload, or an event name that isn’t in the map, is a type error, not a runtime surprise. this is the provider, so injected dependencies work.
How it works, and what to know:
- After the application has booted, the events module scans every singleton provider (and controller) for
@OnEventmethods, including ones inherited from a base class, and subscribes them. This uses theOnApplicationBootstraphook. Handlers in any module are found, not only the module that importsEventsModule. - Because the subscription happens after boot, an event emitted from an
onModuleInitis not seen by@OnEventhandlers. Emit from request handling, or fromonApplicationBootstrap. - Transient providers are never cached, so their handlers are not subscribed. Use singleton providers (the default).
- Handlers are unsubscribed when the application closes.
- Failure behaviour is the same as
on(): a handler that throws is reported (see below) and doesn’t affect the others or the emitter. - Two
defineEventsModule()calls in one app keep separate handlers: each decorator only feeds its own bus. @OnEventon something that isn’t a method fails the boot with the class and member name.
What emit() actually does — and doesn’t
Section titled “What emit() actually does — and doesn’t”The only implementation, InProcessEventBus, runs every handler registered for an event concurrently, and emit() resolves once all of them have settled — success or failure. A handler that throws (or an async handler whose promise rejects) is caught individually: it’s reported, but it never stops sibling handlers from running and never makes emit() itself reject. There’s no ordering guarantee between handlers for the same event.
By default the report is console.error. To send it to your logger, pass onHandlerError to forRoot(): EventsModule.forRoot({ onHandlerError: ({ type, error }) => logger.error("event handler failed", { type, err: error }) }). It also receives the payload the handler was given, which may hold personal data: log only what you need. A hook that throws does not fail emit() either; both failures are written to console.error.
This means:
- No persistence. An event emitted with no process alive to receive it (or a process that crashes mid-handler) is gone. There’s no queue, no retry, no at-least-once delivery.
- No durability across a crash. If the process dies between writing a domain change to the database and calling
emit(), the event is lost — the write and the emit aren’t in the same transaction. - No cross-process delivery. Handlers only see events emitted in the same process. This is a pub-sub bus for one running app, not a message broker.
When you need durability: the outbox
Section titled “When you need durability: the outbox”This package is deliberately the in-process bus, with no persistence. The outbox pattern gives the durability: write the event as a row in the same database transaction as the change it describes, so a crash between the two can never lose it, then deliver the rows out-of-band, at least once, to consumers that tolerate a repeat.
It is not a package, because the table, the consumers and the meaning of a retry are your decisions. The transactional outbox is a worked, tested example (examples/saas-api) you can copy: enqueue() inside the transaction, a relay that claims rows with for update skip locked so several replicas can run it, backoff, parking after too many attempts, and an idempotent consumer.
- Every exported symbol:
@blixis-io/eventsreference. - The same factory-closure pattern used elsewhere:
@blixis-io/config,@blixis-io/tenancy.