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
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 { DomainEventDef } from "@beignet/core/domain";
type Todo = { id: string; title: string; completed: boolean };
type AppCtx = {
ports: {
todos: {
create: (input: { title: string }) => Promise<Todo>;
findById: (id: string) => Promise<Todo>;
};
eventBus: {
publish: <Payload>(
event: DomainEventDef<string>,
payload: Payload,
) => Promise<void>;
};
};
};
const useCase = createUseCase<AppCtx>();
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<AppCtx>({
validate: true, // default
});
const useCaseMetadataOnly = createUseCase<AppCtx>({
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.
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.
Reusing schemas in contracts
Application DTO schemas should live with the use case or feature application
layer. The finalized use case exposes inputSchema and outputSchema as
public properties. Reuse them in HTTP contracts when the request body and
success response match the application operation:
export const createTodoContract = todos
.post("/api/todos")
.body(createTodo.inputSchema)
.responses({
201: createTodo.outputSchema,
});
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
Pass an onRun hook to observe use case execution:
const useCase = createUseCase<AppCtx>({
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<AppCtx>(() => ({
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.
import { allow, definePolicy, deny } from "@beignet/core/ports";
import type { AppContext } from "@/app-context";
import type { Post } from "@/features/posts/ports";
function sameTenant(ctx: AppContext, post: Post) {
if (ctx.tenant?.id === post.tenantId) return allow();
return deny({
reason: "Post belongs to another tenant.",
code: "TENANT_MISMATCH",
});
}
export const postPolicy = definePolicy({
"posts.update": (ctx: AppContext, post: Post) => {
const tenant = sameTenant(ctx, post);
if (!tenant.allowed) return tenant;
return (
ctx.auth?.user.id === post.authorId ||
deny("Only the author can update.")
);
},
"posts.publish": (ctx: AppContext, post: Post) => {
const tenant = sameTenant(ctx, post);
if (!tenant.allowed) return tenant;
return ctx.auth?.user.role === "admin"
? true
: deny("Only admins can publish.");
},
});
Wiring into route handlers
Use cases are called from your route handlers with the current context:
import { createTodo } from "@/features/todos/use-cases";
export const POST = server.route(contracts.createTodo).handle(async ({ ctx, body }) => {
const result = await createTodo.run({ input: body, ctx });
return { status: 201, body: result };
});