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(
"posts.enqueue-published-email",
{
event: PostPublished,
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(SendPostPublishedEmailJob, payload);
},
},
);Listeners should live with domain or application code, then be collected in
server/listeners.ts and registered from server provider wiring.
Register listeners
// server/listeners.ts
import { postListeners } from "@/features/posts/listeners";
export const listeners = [...postListeners] as const;// server/providers.ts
import { registerListeners } from "@beignet/core/events";
import { listeners } from "@/server/listeners";
const unregister = registerListeners(eventBus, listeners, {
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 listener updates server/listeners.ts and provider wiring.
beignet doctor flags listener and event registration drift; see CLI
for the generator and doctor details.
Failure and ordering semantics
Listener delivery is not an independently durable fan-out. On an awaited,
sequential event bus, listeners run in registration order. Without
registerListeners(..., { onError }), a listener failure propagates to the bus
and may prevent later listeners from running. If the transport retries the
event, listeners that already succeeded may run again.
Providing onError reports and consumes that listener failure, allowing a
sequential bus to continue, but Beignet does not then retry the failed listener
independently. Choose this policy deliberately. When every side effect needs
its own retry and dead-letter lifecycle, have listeners dispatch separate
idempotent jobs or record separate outbox messages instead of relying on event
fan-out for delivery isolation.
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 { createMemoryEventBusProvider } from "@beignet/provider-event-bus-memory";
export const providers = [createMemoryEventBusProvider()] as const;Tests and app-owned wiring can also create the bus directly with
createMemoryEventBus() from the same package.
For multi-process best-effort delivery, use the Redis Pub/Sub provider:
bun add @beignet/provider-event-bus-redis ioredis// server/providers.ts
import { createRedisEventBusProvider } from "@beignet/provider-event-bus-redis";
export const providers = [createRedisEventBusProvider()] as const;Set REDIS_EVENT_BUS_URL=redis://localhost:6379, or let the CLI apply the
full preset:
beignet provider add event-bus-redisRedis Pub/Sub gives cross-instance delivery while subscribers are online. It
does not persist messages, replay missed events, acknowledge handlers, retry
failed handlers, or dead-letter failures. Production apps that need durable
event delivery should use Outbox, jobs, or an app-owned durable
transport behind the same event bus port; feature code keeps publishing
through ctx.ports.eventBus either way.
The provider opens separate publisher and subscriber connections in every
process where it is installed. ioredis reconnects and resubscribes after a
transient disconnect, but events published during that gap are lost. Host
subscriptions in long-lived web or worker processes rather than ephemeral
request runtimes. The env-backed provider accepts a standalone or managed
single-endpoint Redis URL; Sentinel and Cluster users should inject app-owned
ioredis clients through createRedisEventBus(...) and validate failover against
their exact topology.
When ports.tracing is installed, the Redis envelope carries Beignet's
versioned trace carrier and registerListeners(...) continues the producer
trace in the subscriber process. Legacy envelopes and malformed trace metadata
still deliver the event payload.
Redis subscribe and unsubscribe command failures are reported through the
provider's onSubscriberError callback and recorded as
eventBus.subscription.failed instrumentation, so a failed listener
registration is visible in enabled devtools or app telemetry. A failed
subscription retries with capped backoff while at least one local handler
remains registered; removing the final handler cancels the retry.
Publish failures reject the caller and record eventBus.publish.failed; the
provider never reports a failed Redis command as successful delivery.
Connection errors from env-backed publisher and subscriber clients are recorded
as eventBus.connection.failed when the watcher is enabled. Without enabled
instrumentation, ioredis's fallback logging remains active.
The canonical generated listener provider also reports handler failures once
from registerListeners(...).onError. Direct listener registration remains
app-owned; do not report again inside a listener handler when the registration
boundary already owns the incident.
See Runtime recipes for the process
topology and readiness implications of best-effort cross-process events.
Where events fit
- Workflow primitives compares events with jobs, schedules, notifications, idempotency keys, and outbox records.
- Jobs covers typed job definitions, dispatchers, and provider workers.
- Mail shows a common event-to-job-to-mail workflow.