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