Use cases

The @beignet/core/application subpath provides a fluent builder for use cases — the core business operations in your application.

bun add @beignet/core

Route handler or use case?

Keep a workflow in a route handler when the endpoint is only transport glue: health checks, simple adapter responses, or request normalization with no business rules. Move the workflow into a use case when it touches ports, owns business decisions, needs direct tests, or may run from HTTP, jobs, scripts, events, or tests.

Use Workflow primitives when deciding whether a use case should record an event, dispatch a job, send a notification, protect itself with idempotency, or write to the outbox.

For multi-step lifecycle flows with durable state, use the workflow/state-machine pattern: keep state in repositories, put each transition in a command use case, and use events/outbox for post-commit work.

Creating a use case builder

Start by creating a builder scoped to your application's context type:

import { createUseCase } from "@beignet/core/application";
import type { AppContext } from "@/app-context";

const useCase = createUseCase<AppContext>();

Use cases validate their input before the handler runs and validate the returned output before resolving. This makes them safe to call from HTTP routes, jobs, scripts, tests, and event handlers.

const useCase = createUseCase<AppContext>({
  validate: true, // default
});

const useCaseMetadataOnly = createUseCase<AppContext>({
  validate: false,
});

Commands and queries

Use .command() for operations that change state and .query() for read-only operations:

import { z } from "zod";

const createTodo = useCase
  .command("todos.create")
  .input(z.object({ title: z.string() }))
  .output(z.object({ id: z.string(), title: z.string(), completed: z.boolean() }))
  .run(async ({ input, ctx }) => {
    return ctx.ports.todos.create(input);
  });

const getTodo = useCase
  .query("todos.get")
  .input(z.object({ id: z.string() }))
  .output(z.object({ id: z.string(), title: z.string(), completed: z.boolean() }))
  .run(async ({ input, ctx }) => {
    return ctx.ports.todos.findById(input.id);
  });

Inside .run(...), input is the parsed schema output. Schema defaults, coercions, and transforms have already been applied.

Reusing schemas in contracts

Application DTO schemas that are shared by contracts, use cases, ports, tests, or client code should live in features/<feature>/schemas.ts. Contracts are client-safe roots, so they should import shared schemas directly instead of importing use cases to reach .inputSchema or .outputSchema.

// features/todos/schemas.ts
import { z } from "zod";

export const CreateTodoInputSchema = z.object({ title: z.string().min(1) });
export const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});
// features/todos/contracts.ts
export const createTodoContract = todos
  .post("/api/todos")
  .body(CreateTodoInputSchema)
  .responses({
    201: TodoSchema,
  });

Keep explicit contract schemas when the HTTP shape differs from the application input or output, such as headers, path params, multipart uploads, or transport wrappers.

Emitting domain events

Use cases can declare which domain events they may emit. The handler receives an events helper scoped to .emits(...), so undeclared events are caught by TypeScript and by runtime checks:

import { defineEvent } from "@beignet/core/events";

const todoCreated = defineEvent("todo.created", {
  payload: z.object({ id: z.string(), title: z.string() }),
});

const createTodo = useCase
  .command("todos.create")
  .input(z.object({ title: z.string() }))
  .output(z.object({ id: z.string(), title: z.string() }))
  .emits([todoCreated])
  .run(async ({ input, ctx, events }) => {
    const todo = await ctx.ports.uow.transaction(async (tx) => {
      const created = await tx.todos.create(input);

      await events.record(tx.events, todoCreated, {
        id: created.id,
        title: created.title,
      });

      return created;
    });

    return todo;
  });

Transactions and buffered events

Use cases are the recommended place to define transaction boundaries. Keep the Unit of Work itself as an app-owned port so the database adapter can decide how to create transaction-scoped repositories.

import type {
  DomainEventRecorderPort,
  UnitOfWorkPort,
} from "@beignet/core/ports";

type TodoTransactionPorts = {
  todos: TodoRepositoryPort;
  events: DomainEventRecorderPort;
};

type AppPorts = {
  todos: TodoRepositoryPort;
  eventBus: EventBusPort;
  uow: UnitOfWorkPort<TodoTransactionPorts>;
};

Inside the use case, call transaction-scoped ports through tx. Record domain events during the transaction and let the adapter validate, parse, and publish them after commit.

const createTodo = useCase
  .command("todos.create")
  .input(CreateTodoInput)
  .output(TodoOutput)
  .emits([todoCreated])
  .run(async ({ ctx, input, events }) => {
    return ctx.ports.uow.transaction(async (tx) => {
      const todo = await tx.todos.create(input);

      await events.record(tx.events, todoCreated, {
        todoId: todo.id,
      });

      return todo;
    });
  });

This avoids publishing events, sending jobs, or triggering side effects when the database work rolls back. For tests and in-memory adapters, createNoopUnitOfWork(...) gives the same shape without pretending to create a real database transaction. After-commit hooks run only after successful work; if they fail, rollback hooks do not run because the work has already completed.

Instrumentation

Use cases are instrumented by default. Each run resolves the provider instrumentation port from ctx.ports, creates a child span from the request's trace context, and records usecase events for start, end, and error phases, plus a correlated error event for failed runs. Without an installed sink, runs stay silent.

// Default: instrumented automatically.
export const useCase = createUseCase<AppContext>();

// Opt out of built-in instrumentation.
const quietUseCase = createUseCase<AppContext>({ instrumentation: false });

Pass an onRun hook to observe use case execution with app-owned logic. It runs in addition to the built-in instrumentation:

const useCase = createUseCase<AppContext>({
  onRun(event) {
    // event.phase: "start" | "end" | "error"
    // event.name, event.kind, event.durationMs
    console.log(`[${event.phase}] ${event.name} (${event.durationMs}ms)`);
  },
});

Validation failures are reported through the same hook as phase: "error".

Validation errors

Use case validation failures throw UseCaseValidationError:

import { UseCaseValidationError } from "@beignet/core/application";

try {
  await createTodo.run({ ctx, input });
} catch (error) {
  if (error instanceof UseCaseValidationError) {
    error.useCaseName;
    error.phase; // "input" | "output"
    error.issues;
  }
}

Testing use cases

Use createUseCaseTester to centralize context setup and run use cases with typed inputs:

import { createUseCaseTester } from "@beignet/core/application";
import { createTodo } from "@/features/todos/use-cases";

const tester = createUseCaseTester<AppContext>(() => ({
  ports: { todos: createInMemoryTodoRepository() },
  requestId: "test-request",
}));

const result = await tester.run(createTodo, { title: "First todo" });

Use a context factory when tests mutate in-memory ports or request-scoped state. Call tester.ctx() when multiple use cases in the same test need to share one context instance.

Authorizing use cases

Use hooks for HTTP boundary authentication, such as rejecting routes that require a signed-in request before parsing business input. Put business authorization in use cases so the same rule runs when the workflow is called from HTTP, jobs, scripts, events, or tests.

Read Authentication for session and hook wiring. Read Authorization for policy placement and testing.

import { appError } from "@/features/shared/errors";

const updatePost = useCase
  .command("posts.update")
  .input(UpdatePostInput)
  .output(PostOutput)
  .run(async ({ ctx, input }) => {
    const post = await ctx.ports.posts.findById(input.id);

    if (!post) {
      throw appError("PostNotFound", { details: { id: input.id } });
    }

    await ctx.gate.authorize("posts.update", post);

    return ctx.ports.posts.update(input.id, input);
  });

Policies are typed app modules registered with createGate(...). Move repeated ownership, role, tenant, plan, or resource-state rules into feature policy files declared with definePolicy(...). See Authorization for writing and testing policies.

Wiring into routes

Routes bind contracts directly to use cases:

import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { createTodo } from "@/features/todos/use-cases";

export const todoRoutes = defineRouteGroup<AppContext>({
  name: "todos",
  routes: [{ contract: contracts.createTodo, useCase: createTodo }],
});

The server validates the request against the contract, maps the parsed parts to the use case input, runs the use case, and returns its output with the contract's sole declared 2xx status. The server owns the input merge and boundary-parse rules; see Route registration. Full handle routes remain available for responses the binder does not cover; call useCase.run({ ctx, input }) yourself there.