Providers

Providers are startup-time adapters. They install concrete ports for databases, caches, storage, mail, 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.

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],
  createContext: ({ ports }) => ({
    requestId: crypto.randomUUID(),
    ports,
  }),
});

Provider-installed ports are available in createContext, route handlers, hooks, use cases, and server.ports.

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
createDrizzleTursoProvider({ 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],
  createContext: ({ ports }) => ({ ports }),
});

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
  ],
  createContext: ({ ports }) => ({ ports }),
});

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.

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.

First-party providers

ConcernPackageInstallsRead next
Database@beignet/provider-drizzle-tursodb plus Drizzle/Turso helpersDatabase and transactions
Cache@beignet/provider-rediscache and Redis client 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
Logger@beignet/provider-logger-pinologgerLogging
Rate limiting@beignet/provider-rate-limit-upstashrateLimitRate limiting
Event bus@beignet/provider-event-bus-memoryeventBusEvents
Auth@beignet/provider-auth-better-authauthAuthentication
Jobs@beignet/provider-inngestjobs, plus inngest escape hatchJobs

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.