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
| Concern | Package | Installs | Read next |
|---|---|---|---|
| Database | @beignet/provider-drizzle-turso | db plus Drizzle/Turso helpers | Database and transactions |
| Cache | @beignet/provider-redis | cache and Redis client 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 | ||
| Logger | @beignet/provider-logger-pino | logger | Logging |
| Rate limiting | @beignet/provider-rate-limit-upstash | rateLimit | 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 |
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.