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.