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 production concerns, read Database and transactions, Audit and activity logging, Cache, Storage, Mail, Jobs, Scheduled tasks, 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/core
Define 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;
Put ports in context
The server passes ports into createContext. From there, handlers and hooks receive them through ctx.ports.
export const server = await createNextServer({
ports: appPorts,
createContext: ({ ports, req }) => ({
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
ports,
}),
});
export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
const todo = await ctx.ports.todos.findById(path.id);
return { status: 200, body: todo };
});
Use ports in use cases
Use cases stay transport-agnostic because they receive the same context shape that handlers use.
import { createUseCase } from "@beignet/core/application";
type AppContext = {
ports: typeof appPorts;
};
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.