Providers
Providers are startup-time adapters. They install concrete ports for databases,
caches, storage, mail, payments, feature flags, auth, logging, jobs, rate limits, and other
external services while handlers and use cases depend only on ctx.ports.
Read Ports and adapters first if you want the dependency boundary. Read the production feature pages when you want a task-specific guide, and Writing a provider when you are building a reusable provider package.
How providers fit
import { createNextServer } from "@beignet/next";
import { loggerPinoProvider } from "@beignet/provider-logger-pino";
import { redisProvider } from "@beignet/provider-redis";
import { appPorts } from "@/infra/app-ports";
export const server = await createNextServer({
ports: appPorts,
providers: [loggerPinoProvider, redisProvider],
context: ({ ports }) => ({
requestId: crypto.randomUUID(),
ports,
}),
});Provider-installed ports are available in context factories, route handlers,
hooks, use cases, and server.ports.
Generated apps keep provider wiring in two places:
infra/app-ports.tsbinds app-owned ports such as the policy gate and declares the rest as deferred provider-contributed keys withdefinePorts<AppPorts>()({ bound, deferred }).server/providers.tsregisters runtime providers in startup order, exportedas constso port types can be inferred from the list.
App-owned infra providers, such as a database provider that wires repositories,
belong under infra/ and are registered from server/providers.ts after the
provider that installs the lower-level port they need.
After all providers have started, the server verifies that every deferred port
was contributed and fails boot with the missing keys otherwise. See
Defer ports to providers for the
onUnboundPorts options.
Typed provider ports
InferProviderPorts extracts and merges the ports a provider list contributes,
so app code can type ctx.ports without hand-written casts:
// app-context.ts
import type { InferProviderPorts } from "@beignet/core/providers";
import type { AppPorts } from "@/ports";
import type { providers } from "@/server/providers";
export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;
export type AppContext = {
requestId: string;
ports: AppRuntimePorts;
};The import of providers is type-only, so app-context.ts stays free of
runtime server dependencies.
App-local providers can declare the ports they require from earlier providers,
plus their app context and service-context input, through the curried
createProvider<Requires, Context, ServiceInput>() form:
import { createProvider } from "@beignet/core/providers";
import type { DbPort } from "@beignet/provider-db-drizzle/sqlite";
import type { AppContext } from "@/app-context";
import type { AppServiceContextInput } from "@/server";
import type { AppPorts } from "@/ports";
import type * as schema from "./schema";
export const appDatabaseProvider = createProvider<
{ db: DbPort<typeof schema> },
AppContext,
AppServiceContextInput
>()({
name: "app-database",
async setup({ ports }) {
const providedPorts: Pick<AppPorts, "posts" | "uow"> = {
...createRepositories(ports.db.db),
uow: createUnitOfWork(ports.db.db),
};
return { ports: providedPorts };
},
});Annotate the returned ports with a Pick<AppPorts, ...> of the keys the
provider fulfills. Writing a provider covers the typing
guidance for setup results and lifecycle hooks in detail.
Naming conventions
Provider exports follow a small naming rule:
// Ready-to-install provider singletons go directly in providers: []
redisProvider
loggerPinoProvider
mailSmtpProvider
// Provider factories accept app-owned runtime input and return a provider
createDrizzleSqliteProvider({ schema })
createAuthBetterAuthProvider(auth)
createInMemoryEventBusProvider()
// Direct port factories return concrete implementations for manual wiring
createInMemoryEventBus()
createMemoryMailer()Use xProvider or createXProvider(...) for Beignet lifecycle providers
registered with providers: []. Use createXPort() or a domain-specific
factory name for direct implementations assigned under ports.
Provider vs port factory
A port factory is just app code that returns one concrete port. It is the right shape for simple dependencies, tests, and one-off adapters.
import { createMemoryMailer } from "@beignet/core/mail";
import { definePorts } from "@beignet/core/ports";
export const appPorts = definePorts({
logger: fallbackLogger,
mailer: createMemoryMailer(),
});A provider participates in server startup. Use one when infrastructure needs configuration loading, setup order, startup checks, teardown, provider instrumentation, or reusable packaging.
export const server = await createNextServer({
ports: appPorts,
providers: [loggerPinoProvider, mailSmtpProvider],
context: appContextBlueprint,
});Setup order
Providers run in the order you pass them to the server. Each provider sees base ports plus ports returned by earlier providers.
export const server = await createNextServer({
ports: appPorts,
providers: [
loggerPinoProvider, // installs ctx.ports.logger
redisProvider, // can see ctx.ports.logger during setup
],
context: appContextBlueprint,
});When two providers return the same port key, the later provider wins. Use that deliberately for environment-specific overrides.
Lifecycle
setup runs during server creation. start runs after all providers have
contributed ports. stop runs when the server is stopped.
Provider lifecycle hooks should do bounded resource work: create clients,
install ports, run startup checks, and close resources. Do not start polling
loops, queue consumers, or other unbounded background work from setup or
start in serverless apps. Put background work behind explicit runtime
entrypoints such as cron routes, scheduled handlers, job functions, or worker
processes.
import { createProvider } from "@beignet/core/providers";
import { z } from "zod";
const CacheConfigSchema = z.object({
URL: z.string().url(),
});
export const cacheProvider = createProvider({
name: "cache",
config: { schema: CacheConfigSchema, envPrefix: "CACHE_" },
async setup({ config }) {
const client = await connectToCache(config.URL);
return {
ports: {
cache: {
get: (key) => client.get(key),
set: async (key, value, options) => {
if (options?.ttlSeconds) {
await client.set(key, value, { ttlSeconds: options.ttlSeconds });
} else {
await client.set(key, value);
}
},
delete: async (key) => client.delete(key),
has: async (key) => (await client.exists(key)) > 0,
remember: async (key, factory, options) => {
const cached = await client.get(key);
if (cached != null) return cached;
const value = await factory();
await client.set(key, value, options?.ttlSeconds);
return value;
},
},
},
async stop() {
await client.close();
},
};
},
});The envPrefix strips the prefix before validation. For example,
CACHE_URL=redis://localhost:6379 becomes { URL: "redis://localhost:6379" }.
Escape hatches
First-party providers expose stable app-facing ports for normal use and raw clients as escape hatches for provider-specific features.
await ctx.ports.mailer.send({
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
await ctx.ports.resend.client.emails.send({
from: "sender@example.com",
to: "user@example.com",
subject: "Invoice",
html: "<p>Attached.</p>",
attachments: [{ filename: "invoice.pdf", content: pdfBuffer }],
});Application code should prefer the stable port. Use the escape hatch only when the provider has a feature the port intentionally does not model. Each capability page lists the escape-hatch port its providers install.
First-party providers
Provider packages are named provider-<capability>-<implementation>. When an
implementation spans multiple database backends, each backend is a subpath
export: the Drizzle package ships @beignet/provider-db-drizzle/sqlite,
/postgres, and /mysql, with database drivers as optional peer dependencies
so apps install only the driver they use.
| Concern | Package | Installs | Read next |
|---|---|---|---|
| Database | @beignet/provider-db-drizzle | db plus per-backend Drizzle helpers via /sqlite, /postgres, and /mysql | Database and transactions |
| Cache | @beignet/provider-redis | cache, plus redis escape hatch | Cache |
| Storage | @beignet/provider-storage-local, @beignet/provider-storage-s3 | storage, plus s3Storage for S3-compatible provider escape hatch | Storage |
@beignet/provider-mail-resend, @beignet/provider-mail-smtp | mailer, plus resend or smtp escape hatch | ||
| Payments | @beignet/provider-payments-stripe | payments, plus stripe escape hatch | Payments and billing |
| Feature flags | @beignet/provider-flags-openfeature | flags, plus openFeature escape hatch | Feature flags |
| Error reporting | @beignet/provider-error-reporting-sentry | errorReporter, plus sentry escape hatch | Error reporting |
| Logger | @beignet/provider-logger-pino | logger | Logging |
| Rate limiting | @beignet/provider-rate-limit-upstash | rateLimit, plus upstash escape hatch | Rate limiting |
| Event bus | @beignet/provider-event-bus-memory | eventBus | Events |
| Auth | @beignet/provider-auth-better-auth | auth | Authentication |
| Jobs | @beignet/provider-inngest | jobs, plus inngest escape hatch | Jobs |
Provider packages
Reusable provider packages carry conventions beyond the runtime object: a
static beignet.provider metadata manifest in package.json that
beignet doctor reads, provider instrumentation so external work appears in
devtools, and explicit durable-workflow semantics for providers that
participate in jobs, events, schedules, or outbox delivery.
Writing a provider covers all of these.
Testing
For tests, pass mock or memory ports directly instead of booting production providers:
const testPorts = definePorts({
posts: createInMemoryPostRepository(),
mailer: createMemoryMailer(),
logger: {
info: () => {},
error: () => {},
},
});Handlers and use cases still receive ctx.ports, so production and test code
paths stay the same.