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/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;

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:

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.