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.

Organize use cases

Start with named exports in features/<feature>/use-cases.ts. Each export uses the same useCase.command(...) or .query(...) builder. Keep shared schemas in schemas.ts, HTTP declarations in contracts.ts, and route bindings in routes.ts; browser components must not import use cases.

Split a module when workflows develop substantial independent logic, dependencies, or helpers. Move the declarations into use-cases/, adjust their relative imports, and re-export the same names from use-cases/index.ts. Remove use-cases.ts once the directory is ready. Imports such as import { createProjectUseCase } from "./use-cases" keep working.

The starter and new basic features use the compact form. The CLI follows each existing feature's layout; select --useCaseLayout split when generating a new feature that needs separate files. Both forms have the same validation, authorization, architecture checks, and runtime behavior. See use-case generation.

Creating a use case builder

Use the Todos app from Quickstart. It already declares the app-bound builder in lib/use-case.ts; import that builder in feature use cases. Outside the starter, create this file once:

// lib/use-case.ts
import "@beignet/core/server-only";
import { createUseCase } from "@beignet/core/application";
import type { AppContext } from "@/app-context";

export const useCase = createUseCase<AppContext>();

Use cases validate input before the handler runs and output before resolving. Schema defaults and transforms have already been applied to input inside .run(...).

Commands and queries

Use .command() for writes and .query() for reads. The starter keeps its four workflows in one module. Each command authorizes the write; the list query limits reads to the signed-in user. This complete file uses the starter's existing schemas, ports, and policies:

// features/todos/use-cases.ts
import "@beignet/core/server-only";
import { requireUser } from "@/lib/auth";
import { useCase } from "@/lib/use-case";
import {
  CreateTodoInputSchema,
  DeleteTodoOutputSchema,
  ListTodosInputSchema,
  ListTodosOutputSchema,
  TodoIdInputSchema,
  TodoSchema,
  UpdateTodoInputSchema,
} from "./schemas";
import { appError } from "@/features/shared/errors";
import { normalizeOffsetPage } from "@beignet/core/pagination";

export const createTodoUseCase = useCase
  .command("todos.create")
  .input(CreateTodoInputSchema)
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    const user = requireUser(ctx);
    await ctx.gate.authorize("todos.create");

    return ctx.ports.uow.transaction(async (tx) =>
      tx.todos.create({ userId: user.id, title: input.title }),
    );
  });

export const deleteTodoUseCase = useCase
  .command("todos.delete")
  .input(TodoIdInputSchema)
  .output(DeleteTodoOutputSchema)
  .run(async ({ ctx, input }) => {
    requireUser(ctx);

    await ctx.ports.uow.transaction(async (tx) => {
      const todo = await tx.todos.findById(input.id);
      if (!todo) {
        throw appError("TodoNotFound", { details: { id: input.id } });
      }

      await ctx.gate.authorize("todos.delete", todo);

      await tx.todos.delete(input.id);
    });
  });

export const listTodosUseCase = useCase
  .query("todos.list")
  .input(ListTodosInputSchema)
  .output(ListTodosOutputSchema)
  .run(async ({ ctx, input }) => {
    const user = requireUser(ctx);
    const page = normalizeOffsetPage(input, {
      defaultLimit: 20,
      maxLimit: 100,
    });

    return ctx.ports.todos.list(user.id, page);
  });

export const updateTodoUseCase = useCase
  .command("todos.update")
  .input(UpdateTodoInputSchema)
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    requireUser(ctx);

    return ctx.ports.uow.transaction(async (tx) => {
      const todo = await tx.todos.findById(input.id);
      if (!todo) {
        throw appError("TodoNotFound", { details: { id: input.id } });
      }

      await ctx.gate.authorize("todos.update", todo);

      return tx.todos.update(input.id, { completed: input.completed });
    });
  });

Run the existing behavior tests:

bun run typecheck
bun run test features/todos/tests/todos.test.ts

Expect create, list, update, delete, and access-control tests to pass. In the running app, create a Todo and reload the list; a second account should not see it.

Reusing schemas in contracts

Keep DTO schemas in features/<feature>/schemas.ts so contracts, use cases, ports, and clients share validation without importing server-only workflows. The starter's createTodo contract already reuses CreateTodoInputSchema and TodoSchema:

// features/todos/contracts.ts (excerpt)
export const createTodo = todos
  .post("/api/todos")
  .body(CreateTodoInputSchema)
  .meta({ idempotency: { header: "idempotency-key", scope: "actor" } })
  .errors({ Forbidden: errors.Forbidden })
  .responses({ 201: TodoSchema });

Keep separate contract schemas when the HTTP input differs from the application input, such as path parameters combined with a body. The starter's update route combines TodoIdInputSchema and UpdateTodoBodySchema before calling the use case.

Emitting domain events

Use .emits(...) to declare the events a use case can record or publish. Complete the Outbox walkthrough first to add TodoCompleted and wire tx.events to the transaction's outbox. Then the update command can record completion after an authorized write:

// features/todos/use-cases.ts (excerpt)
import { TodoCompleted } from "@/features/todos/domain/events/completed";

export const updateTodoUseCase = useCase
  .command("todos.update")
  .input(UpdateTodoInputSchema)
  .output(TodoSchema)
  .emits([TodoCompleted])
  .run(async ({ ctx, input, events }) => {
    requireUser(ctx);
    return ctx.ports.uow.transaction(async (tx) => {
      const todo = await tx.todos.findById(input.id);
      if (!todo) throw appError("TodoNotFound", { details: { id: input.id } });
      await ctx.gate.authorize("todos.update", todo);
      const updated = await tx.todos.update(input.id, { completed: input.completed });
      if (!todo.completed && updated.completed) {
        await events.record(tx.events, TodoCompleted, { id: updated.id });
      }
      return updated;
    });
  });

The events helper checks .emits(...) at compile time and at runtime. Declaring an event alone does not install a recorder or a delivery adapter.

Transactions and buffered events

Use cases own transaction boundaries. Call repositories through tx for every write that must commit together. An outbox-backed recorder stores the event in the same transaction; drains deliver it later. A buffered recorder instead publishes after commit and can lose delivery if the process stops before flushing. See Database transactions for that alternative.

For tests, createNoopUnitOfWork(...) supplies transaction-shaped ports without SQL rollback. Use a real database test when verifying commit and rollback. After-commit failures cannot undo a committed database write; use idempotency when callers may retry after an uncertain result.

Instrumentation

Use cases are instrumented by default. Each run resolves the provider instrumentation port from ctx.ports 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.

When ctx.ports.tracing is installed, the same run executes inside an active beignet.use_case <name> span. It inherits the current request or workflow span and stays active across asynchronous work. See OpenTelemetry for provider setup and propagation limits.

// 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". The observer is best-effort: synchronous throws and rejected promises are ignored, so observability code cannot change the use-case result or mask its original error.

Validation errors

Use case validation failures throw UseCaseValidationError:

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

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

Testing use cases

The starter's features/todos/tests/todos.test.ts uses createUseCaseTester with a fresh context and in-memory repository for each test. It verifies owner scoping, validation, and catalog errors. Run it after editing the examples above.

For a new feature, follow Use case tests to create context and port fixtures. Reuse one context when several commands belong to the same scenario; create a new context between independent tests.

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.

This is the update command from use-cases.ts. If you added event recording above, keep that version; it already includes these authorization checks.

// features/todos/use-cases.ts (excerpt)
import "@beignet/core/server-only";
import { appError } from "@/features/shared/errors";
import { requireUser } from "@/lib/auth";
import { useCase } from "@/lib/use-case";
import { TodoSchema, UpdateTodoInputSchema } from "./schemas";

export const updateTodoUseCase = useCase
  .command("todos.update")
  .input(UpdateTodoInputSchema)
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    requireUser(ctx);

    return ctx.ports.uow.transaction(async (tx) => {
      const todo = await tx.todos.findById(input.id);
      if (!todo) {
        throw appError("TodoNotFound", { details: { id: input.id } });
      }

      await ctx.gate.authorize("todos.update", todo);

      return tx.todos.update(input.id, { completed: input.completed });
    });
  });

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. This excerpt shows the create entry; keep the starter's list, update, and delete entries too:

// features/todos/routes.ts (excerpt)
import { defineRouteGroup } from "@/lib/routes";
import { createTodoUseCase } from "@/features/todos/use-cases";
import { createTodo } from "@/features/todos/contracts";

export const todoRoutes = defineRouteGroup({
  name: "todos",
  routes: [{ contract: createTodo, useCase: createTodoUseCase }],
});

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. A sole path, query, or body schema is passed through unchanged when no additional path, query, or object body values are present, so scalar and array request bodies retain their shape. Multiple sources use the documented object merge. The server owns these input 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.

API reference