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

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 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.

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 providers add event-bus-redis

Redis 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. See Runtime recipes for the process topology and readiness implications of best-effort cross-process events.

Where events fit