Events
Events are facts that happened in your domain. Use an event when the code says "this happened" and multiple parts of the app may care: a post was published, a user registered, an invoice was paid, or a comment was added.
Beignet events are typed definitions. Event buses and listeners decide how the fact is delivered.
bun add @beignet/core
Define an event
import { defineEvent } from "@beignet/core/events";
import { z } from "zod";
export const PostPublished = defineEvent("post.published", {
payload: z.object({
postId: z.string().uuid(),
slug: z.string(),
publishedAt: z.string().datetime(),
}),
});
The event name is the stable identity. The payload schema validates data before publication and before listener execution.
Emit events from use cases
Use cases declare which events they may emit with .emits(...). The handler
receives an events helper scoped to that declaration:
const publishPost = useCase
.command("posts.publish")
.input(PublishPostInput)
.output(PostOutput)
.emits([PostPublished])
.run(async ({ ctx, input, events }) => {
return ctx.ports.uow.transaction(async (tx) => {
const published = await tx.posts.publish(input.slug);
await events.record(tx.events, PostPublished, {
postId: published.id,
slug: published.slug,
publishedAt: published.publishedAt,
});
return published;
});
});
events.record(...) catches undeclared events at compile time and throws
UseCaseEventDeclarationError if an undeclared event is emitted dynamically.
Record events inside transactions
When a workflow uses Unit of Work, record domain events inside the transaction and flush them after commit:
const post = await ctx.ports.uow.transaction(async (tx) => {
const published = await tx.posts.publish(input.slug);
await events.record(tx.events, PostPublished, {
postId: published.id,
slug: published.slug,
publishedAt: published.publishedAt,
});
return published;
});
If the transaction rolls back, recorded events are discarded. If it commits, the Unit of Work adapter validates, parses, and publishes the buffered events. This prevents listeners, jobs, and mail from observing data that never committed.
Use Database and transactions for the full Unit of Work pattern.
For simple non-transactional workflows, call
events.publish(ctx.ports.eventBus, PostPublished, payload). It validates and
parses the payload before publishing through the event bus.
Define listeners
Listeners react to events. Bind your app context once so listener ctx is
typed:
import { createEventHandlers } from "@beignet/core/events";
import type { AppContext } from "@/app-context";
import { PostPublished } from "@/features/posts/domain/events";
const events = createEventHandlers<AppContext>();
export const enqueuePublishedEmail = events.defineListener(PostPublished, {
name: "posts.enqueue-published-email",
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload);
},
});
Listeners should live with domain or application code, then be registered from infrastructure startup.
Register listeners
import { registerListeners } from "@beignet/core/events";
import { postListeners } from "@/features/posts/listeners";
const unregister = registerListeners(eventBus, postListeners, {
ctx,
onError(error, listener) {
ctx.ports.logger.error("Listener failed", {
error,
listener: listener.name,
});
},
});
Call unregister() during teardown when your runtime has a long-lived process.
Event bus adapters
Use the in-memory event bus for local development, tests, and single-process apps:
bun add @beignet/provider-event-bus-memory
import {
createInMemoryEventBus,
createInMemoryEventBusProvider,
} from "@beignet/provider-event-bus-memory";
const eventBus = createInMemoryEventBus();
const eventBusProvider = createInMemoryEventBusProvider();
Production apps that need durable event delivery should adapt their queue, stream, or outbox behind the same event bus port.
Events vs jobs
Use an event when the code says "this happened" and many parts of the app may care. Use a job when the code says "do this work" and one handler owns the work.