Use cases
The @beignet/core/application subpath provides a fluent builder for use cases — the core business operations in your application.
bun add @beignet/coreRoute 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.tsExpect 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.