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/coreDefine 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
Recording through tx.events keeps events transactional: if the transaction
rolls back, recorded events are discarded; if it commits, the Unit of Work
validates, parses, and publishes them. See
side effects after commit for the
rule, Database and transactions for the Unit of
Work wiring, and Outbox when event delivery itself must be durable
ā the outbox keeps the same events.record(tx.events, ...) API but records
events as database rows and drains them after commit with retries.
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. Create the app-bound defineListener builder once
in lib/listeners.ts with createListeners<AppContext>() (see
app-bound builders), then define listeners in
feature files:
import { defineListener } from "@/lib/listeners";
import { PostPublished } from "@/features/posts/domain/events";
export const enqueuePublishedEmail = 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.
beignet make event scaffolds and registers new events, and beignet doctor
flags unregistered listeners and events; see CLI for the generator and
doctor details.
Event bus adapters
The starter ships no event bus. beignet make event and
beignet make resource --events add the eventBus: EventBusPort port and
register the memory provider when the app does not have one yet, and skip the
wiring when the ports file already mentions eventBus. The in-memory bus
suits local development, tests, and single-process apps:
// server/providers.ts
import { createInMemoryEventBusProvider } from "@beignet/provider-event-bus-memory";
export const providers = [createInMemoryEventBusProvider()] as const;Tests and app-owned wiring can also create the bus directly with
createInMemoryEventBus() from the same package.
To swap in a real bus, keep the eventBus: EventBusPort entry in AppPorts
and replace the provider registration in server/providers.ts with an adapter
for your queue or stream. Production apps that need durable event delivery
should use Outbox or adapt their transport behind the same event
bus port; feature code keeps publishing through ctx.ports.eventBus either
way.
Where events fit
- Workflow primitives compares events with jobs, schedules, notifications, idempotency keys, and outbox records.
- Jobs covers typed job definitions, dispatchers, and Inngest workers.
- Mail shows a common event-to-job-to-mail workflow.