Ports and adapters
Ports are the dependency interface your application code uses. They keep handlers and use cases independent from infrastructure choices such as databases, caches, mailers, queues, auth systems, and external APIs.
For concrete app capabilities, read Database and transactions, Audit and activity logging, Cache, Storage, Mail, Jobs, Schedules, Authentication, Authorization, and Rate limiting.
In Beignet apps, ports/ owns the app-facing types and infra/ owns
concrete implementations. Most apps start with a single definePorts(...)
object and split port types by feature or capability as the app grows.
bun add @beignet/coreDefine ports
Use definePorts to capture the exact shape of your dependencies when you wire
concrete ports.
import { definePorts } from "@beignet/core/ports";
type Todo = { id: string; title: string; completed: boolean };
export const appPorts = definePorts({
todos: {
findById: async (id: string): Promise<Todo | null> => {
return db.todos.findById(id);
},
create: async (data: { title: string }): Promise<Todo> => {
return db.todos.create(data);
},
},
cache: {
get: async (key: string) => redis.get(key),
set: async (key: string, value: string, options?: { ttlSeconds?: number }) => {
await redis.set(key, value, options?.ttlSeconds);
},
delete: async (key: string) => (await redis.del(key)) > 0,
has: async (key: string) => (await redis.exists(key)) > 0,
remember: async (key: string, factory: () => Promise<string>, options?: { ttlSeconds?: number }) => {
const cached = await redis.get(key);
if (cached != null) return cached;
const value = await factory();
await redis.set(key, value, options?.ttlSeconds);
return value;
},
},
});
export type AppPorts = typeof appPorts;Defer ports to providers
Production apps usually bind a few app-owned ports directly and let
providers contribute the rest at server startup. Use the curried
definePorts<AppPorts>()(...) form to declare which keys are deferred instead
of writing throwing stub implementations:
import { definePorts } from "@beignet/core/ports";
import type { AppPorts } from "@/ports";
export const appPorts = definePorts<AppPorts>()({
bound: { gate },
deferred: ["audit", "db", "logger", "mailer", "storage", "uow"],
});Deferred keys boot as marked placeholders. Calling any method on one throws a
descriptive error naming the port, and createServer(...) validates after
provider startup that nothing is left unbound:
- The default
onUnboundPorts: "error"fails boot and lists the unbound keys. "warn"logs the same message and continues."ignore"skips the check; unbound ports still throw on first use. Tests that boot a server with only the ports they exercise typically use this.
export const server = await createNextServer({
ports: appPorts,
providers,
onUnboundPorts: "error", // default
context: appContextBlueprint,
});Put ports in context
The server passes ports into the context factories. From there, use cases,
handlers, and hooks receive them through ctx.ports.
import { createNextServer } from "@beignet/next";
import type { AppContext } from "@/app-context";
export const server = await createNextServer({
ports: appPorts,
context: ({ ports, req }) => ({
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
ports,
}),
});import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { getTodoUseCase } from "@/features/todos/use-cases";
export const todoRoutes = defineRouteGroup<AppContext>({
name: "todos",
routes: [{ contract: getTodo, useCase: getTodoUseCase }],
});Use ports in use cases
Use cases stay transport-agnostic because they receive the same context shape that route handlers use.
import { createUseCase } from "@beignet/core/application";
import type { AppContext } from "@/app-context";
const useCase = createUseCase<AppContext>();
const createTodo = useCase
.command("todos.create")
.input(CreateTodoSchema)
.output(TodoSchema)
.run(async ({ input, ctx }) => {
const todo = await ctx.ports.todos.create(input);
await ctx.ports.cache.delete("todos:list");
return todo;
});Unit of work
Use a Unit of Work port when a use case needs multiple operations to succeed or fail together. Beignet keeps this as a convention instead of a database abstraction: your app owns the transaction ports, and infra decides how to bind them to Drizzle, Prisma, Kysely, or an in-memory adapter.
import type {
DomainEventRecorderPort,
EventBusPort,
UnitOfWorkPort,
} from "@beignet/core/ports";
type TransactionPorts = {
todos: TodoRepository;
events: DomainEventRecorderPort;
};
export type AppPorts = {
todos: TodoRepository;
eventBus: EventBusPort;
uow: UnitOfWorkPort<TransactionPorts>;
};Use cases call transaction-scoped ports through the callback:
const todo = await ctx.ports.uow.transaction(async (tx) => {
const created = await tx.todos.create(input);
await events.record(tx.events, todoCreated, { todoId: created.id });
return created;
});For tests and simple in-memory adapters, use createNoopUnitOfWork(...) with a
fresh domain-event recorder per transaction:
import {
createDomainEventRecorder,
createNoopUnitOfWork,
definePorts,
} from "@beignet/core/ports";
const todos = createInMemoryTodoRepository();
const eventBus = createInMemoryEventBus();
export const appPorts = definePorts({
todos,
eventBus,
uow: createNoopUnitOfWork(
() => ({
todos,
events: createDomainEventRecorder(),
}),
{
afterCommit: (tx) => tx.events.flush(eventBus),
afterRollback: (_error, tx) => tx.events.clear(),
},
),
});Production database adapters should replace createNoopUnitOfWork with a real
transaction wrapper that creates repositories from the transaction client, then
flushes recorded events only after commit. Flush validates and parses each event
payload before publishing. If after-commit flushing fails, the UOW rejects
without running rollback hooks because the transaction work already succeeded.
Mock ports in tests
Tests can pass plain objects instead of production infrastructure.
const testPorts = definePorts({
todos: {
findById: async (id: string) => ({ id, title: "Test", completed: false }),
create: async (data: { title: string }) => ({
id: "1",
completed: false,
...data,
}),
},
cache: {
get: async () => null,
set: async () => {},
delete: async () => false,
has: async () => false,
remember: async (_key, factory) => factory(),
},
});Shared redaction
Ports also provides shared redaction helpers for observability payloads. Use them in audit adapters, provider instrumentation, logging metadata, and devtools custom events when structured details may contain secrets:
import { redactHeaders, redactValue } from "@beignet/core/ports";
const headers = redactHeaders(req.headers);
const metadata = redactValue({
authorization: "Bearer secret",
todoId: "todo_1",
});The default redactor hides secret-shaped keys. App-specific sensitive domain fields should still be handled intentionally before they are logged or stored.
Ports vs providers
Ports are the interface. Providers are startup-time adapters that install ports for production use.
Use direct ports when the dependency is simple or test-local. Use providers when the dependency needs configuration, startup, teardown, or reusable packaging.