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:

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.

ConcernPackageInstallsRead next
Database@beignet/provider-db-drizzledb plus per-backend Drizzle helpers via /sqlite, /postgres, and /mysqlDatabase and transactions
Cache@beignet/provider-rediscache, plus redis escape hatchCache
Storage@beignet/provider-storage-local, @beignet/provider-storage-s3storage, plus s3Storage for S3-compatible provider escape hatchStorage
Mail@beignet/provider-mail-resend, @beignet/provider-mail-smtpmailer, plus resend or smtp escape hatchMail
Payments@beignet/provider-payments-stripepayments, plus stripe escape hatchPayments and billing
Feature flags@beignet/provider-flags-openfeatureflags, plus openFeature escape hatchFeature flags
Error reporting@beignet/provider-error-reporting-sentryerrorReporter, plus sentry escape hatchError reporting
Logger@beignet/provider-logger-pinologgerLogging
Rate limiting@beignet/provider-rate-limit-upstashrateLimit, plus upstash escape hatchRate limiting
Event bus@beignet/provider-event-bus-memoryeventBusEvents
Auth@beignet/provider-auth-better-authauthAuthentication
Jobs@beignet/provider-inngestjobs, plus inngest escape hatchJobs

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.