Writing a provider

This page is for provider authors. A port is the app-facing interface, a provider adapts an external system to Beignet at startup, and an adapter in infra/ wires them into app ports — see Ports and adapters and Providers for the app-side view.

A reusable provider package combines four things: a createProvider(...) definition with a bounded lifecycle, typed contributed ports, provider instrumentation, and a static metadata manifest in package.json.

Lifecycle

Define providers with createProvider(...) from @beignet/core/providers. setup runs during server creation and returns the contributed ports plus optional hooks: start runs after all providers have contributed ports, and stop runs when the server is stopped.

import { createProvider } from "@beignet/core/providers";
import { z } from "zod";

export const searchProvider = createProvider({
  name: "search",
  config: {
    schema: z.object({ API_KEY: z.string(), REGION: z.string().optional() }),
    envPrefix: "SEARCH_",
  },
  async setup({ config, ports }) {
    const client = await connectSearch(config.API_KEY, config.REGION);

    return {
      ports: { search: createSearchPort(client) },
      async stop() {
        await client.close();
      },
    };
  },
});

config accepts any Standard Schema library. envPrefix reads matching environment variables and strips the prefix before validation, so SEARCH_API_KEY becomes { API_KEY: ... }.

Lifecycle hooks should do bounded resource work: create clients, run startup checks, 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 entrypoints such as cron routes, job functions, or worker processes.

Inside setup, ports contains base app ports plus ports contributed by earlier providers, and createServiceContext is a late-bound factory for app service contexts. Calling it before all providers have started throws, so only invoke it lazily from runtime entrypoints such as job dispatch or listeners.

Contributing ports and typing

Name exports after the conventions on Providers: xProvider for ready-to-install singletons, createXProvider(...) for factories that take options, createXPort() or a domain factory for direct implementations.

App-local providers should use the curried createProvider<Requires, Context, ServiceInput>() form to declare the ports they require from earlier providers plus their app context and service-context input. Inside setup, ports is typed as the declared requirements and createServiceContext returns the app context.

Annotate the returned ports with a Pick<AppPorts, ...> of the keys the provider fulfills:

const providedPorts: Pick<AppPorts, "posts" | "uow"> = {
  ...repositories,
  uow: createUnitOfWork(ports.db.db),
};

return { ports: providedPorts };

That keeps AppRuntimePorts aligned with the app port contracts instead of intersecting concrete adapter types. Two related inference details:

When the port your provider installs is a stable Beignet port such as CachePort or MailerPort, also expose the raw client under a provider-specific key as an escape hatch, for example redis or resend.

Instrumentation

Add provider instrumentation when a provider performs meaningful external work that should appear in devtools. Use createProviderInstrumentation() from @beignet/core/providers instead of depending on devtools directly:

import {
  createProvider,
  createProviderInstrumentation,
} from "@beignet/core/providers";

export const searchProvider = createProvider({
  name: "search",
  setup({ ports }) {
    const instrumentation = createProviderInstrumentation(ports, {
      providerName: "search",
      watcher: "custom",
    });

    return {
      ports: {
        search: {
          async query(text: string) {
            const results = await runSearch(text);

            instrumentation.custom({
              name: "search.query",
              label: "Search query",
              summary: `${results.length} results`,
              details: { resultCount: results.length },
            });

            return results;
          },
        },
      },
    };
  },
});

The helper accepts a ports object or an instrumentation port and resolves the sink in one canonical order: ports.instrumentation, then ports.devtools. With no sink installed, recording is a no-op. The helper also checks watcher enablement, applies Beignet's default redaction to event details, attaches providerName, and swallows sink failures so instrumentation can never break provider work.

Use the watcher that matches the provider's category — db, cache, storage, uploads, mail, notifications, auth, audit, rateLimit, jobs, outbox, schedules, or eventBus — and custom or a custom watcher name for application-specific integrations.

Package metadata manifest

Reusable provider packages declare static metadata in package.json under beignet.provider. It is side-effect-free and lets Beignet tooling inspect installed provider packages without importing provider code, peer dependencies, or environment-sensitive modules.

{
  "name": "@acme/beignet-provider-search",
  "beignet": {
    "provider": {
      "displayName": "Search provider",
      "ports": ["search"],
      "appPorts": [{ "name": "search", "type": "SearchPort" }],
      "env": ["SEARCH_API_KEY", "SEARCH_REGION"],
      "requiredEnv": ["SEARCH_API_KEY"],
      "registration": {
        "required": true,
        "tokens": ["searchProvider", "createSearchProvider"]
      },
      "watchers": ["custom"]
    }
  }
}

env lists all environment variables the provider may read; requiredEnv is the subset that beignet doctor --strict should require in app config.

registration.required: true marks providers that apps must register in server/providers.ts; doctor reports a missing registration as a warning, which fails beignet doctor --strict. Optional-by-design providers such as @beignet/devtools declare registration.severity: "hint" instead, so an installed-but-unregistered package is reported as an informational hint that never fails doctor. tokens lists the export names doctor looks for in server/providers.ts.

beignet doctor --strict reads this metadata to check the generated app convention: installed lifecycle provider packages registered, app-facing provider ports declared in ports/index.ts, required env vars present in app config. Malformed metadata is reported before provider-derived diagnostics are used. Validate the manifest shape with parseProviderPackageMetadata:

import { parseProviderPackageMetadata } from "@beignet/core/providers";

const result = parseProviderPackageMetadata(packageJson.beignet?.provider);

Provider objects can also carry runtime-inert metadata (packageName, ports, requires, env, watchers) for app-local tooling and custom diagnostics.

Variants

Packages whose subpath exports target different backends declare per-backend metadata under variants instead of one top-level requirement set. The first-party example is the Drizzle database provider, where each subpath reads different env vars and registers a different factory:

{
  "beignet": {
    "provider": {
      "displayName": "Drizzle database provider",
      "ports": ["db"],
      "env": [
        "SQLITE_DB_URL",
        "SQLITE_DB_AUTH_TOKEN",
        "POSTGRES_DB_URL",
        "MYSQL_DB_URL"
      ],
      "watchers": ["db"],
      "variants": [
        {
          "name": "sqlite",
          "displayName": "Drizzle SQLite provider",
          "env": ["SQLITE_DB_URL", "SQLITE_DB_AUTH_TOKEN"],
          "requiredEnv": ["SQLITE_DB_URL"],
          "registration": {
            "required": true,
            "tokens": ["drizzleSqliteProvider", "createDrizzleSqliteProvider"]
          }
        },
        {
          "name": "postgres",
          "displayName": "Drizzle Postgres provider",
          "env": ["POSTGRES_DB_URL"],
          "requiredEnv": ["POSTGRES_DB_URL"],
          "registration": {
            "required": true,
            "tokens": [
              "drizzlePostgresProvider",
              "createDrizzlePostgresProvider"
            ]
          }
        },
        {
          "name": "mysql",
          "displayName": "Drizzle MySQL provider",
          "env": ["MYSQL_DB_URL"],
          "requiredEnv": ["MYSQL_DB_URL"],
          "registration": {
            "required": true,
            "tokens": ["drizzleMysqlProvider", "createDrizzleMysqlProvider"]
          }
        }
      ]
    }
  }
}

Each variant accepts name, optional displayName, env, requiredEnv, and registration with the same shapes as the top-level fields. Top-level requiredEnv and registration must be absent when variants is present; declare them on each variant instead, and parseProviderPackageMetadata rejects manifests that mix the two.

Doctor checks variant packages per detected variant: it matches each variant's registration.tokens against server/providers.ts, requires the requiredEnv of only the variants the app actually registers, and — when no variant is detected — reports a single registration diagnostic that lists every variant so the app can pick one.

Durable workflow conventions

Providers that participate in jobs, events, schedules, or outbox delivery must be explicit about the failure semantics they own. Do not silently downgrade a Beignet retry policy.

Provider behaviorRequirement
Implements Beignet retry and dead-letter behaviorStore attempts, compute backoff or accept Beignet's computed retry time, and expose terminal failure state.
Maps to an external provider retry modelDocument the mapping, preserve Beignet's total-attempt language, and fail fast when the external provider cannot honor backoff, jitter, or retry classification.
Runs work inline or in memoryDocument that delivery is not durable and that process crashes can lose work.
Starts background workPut workers behind explicit entrypoints such as cron routes, job functions, or worker processes. Do not start unbounded loops from serverless provider lifecycle hooks.

First-party examples: @beignet/provider-db-drizzle implements the durable outbox port with claim leases, attempts, retry timing, and dead-letter state; @beignet/provider-inngest maps Beignet job total attempts to Inngest retries and rejects retry fields Inngest cannot honor; @beignet/provider-event-bus-memory is deterministic for tests but documents that it is not a durable delivery provider.

Testing expectations

Provider packages ship colocated tests covering both the port behavior and the provider adaptation: the direct factory against the port contract, the provider's config loading and lifecycle, and instrumentation events when the provider records them. Keep app-specific conventions out of the package; ship strong defaults and a README with setup docs instead.

@beignet/provider-event-bus-memory is a compact reference implementation: a direct port factory (createInMemoryEventBus), a provider factory (createInMemoryEventBusProvider) that passes ports through to createProviderInstrumentation, typed contributed ports, and colocated tests.