Feature flags

Feature flags let production apps ship changes behind typed runtime decisions without coupling use cases to a vendor SDK. Beignet owns the flag definitions and FlagsPort; providers decide where evaluation happens.

The API follows OpenFeature's core semantics — typed values, targeting context, defaults, evaluation details, and tracking — while keeping imports and tests in Beignet's port/provider model.

Define flags

Declare flags in the feature that owns the behavior:

// features/billing/flags.ts
import { defineFlag, defineFlags } from "@beignet/core/flags";

export const billingFlags = defineFlags({
  newCheckout: defineFlag.boolean("billing.new-checkout", {
    default: false,
    description: "Route checkout creation through the new billing flow.",
  }),
  pricingCopy: defineFlag.string("billing.pricing-copy", {
    default: "control",
  }),
});

String and number flags widen to string and number by default. Pass a generic when the app wants a closed set of variants, such as defineFlag.string<"control" | "treatment">(...).

Evaluate in application code

Use cases evaluate flags through ctx.ports.flags. Provider failures return the flag default instead of throwing into product workflows; call details(...) when you need the reason, variant, metadata, or error summary.

const enabled = await ctx.ports.flags.evaluate(billingFlags.newCheckout, {
  context: {
    targetingKey: requireUserId(ctx),
    subject: { type: "user", id: requireUserId(ctx) },
    tenant: ctx.tenant,
    attributes: { plan: account.plan },
    privateAttributes: ["email"],
    requestId: ctx.requestId,
    traceId: ctx.traceId,
  },
});

Plain evaluation does not record exposure. Record exposure explicitly when the user actually sees or can be affected by the flagged behavior:

await ctx.ports.flags.recordExposure(billingFlags.newCheckout, {
  context: flagContext,
  value: enabled,
});

Setup with OpenFeature

Use the OpenFeature provider to adapt LaunchDarkly, Statsig, Unleash, flagd, or any other OpenFeature-compatible provider behind Beignet's FlagsPort:

bun add @beignet/provider-flags-openfeature @openfeature/server-sdk
import { createNextServer } from "@beignet/next";
import { createOpenFeatureFlagsProvider } from "@beignet/provider-flags-openfeature";
import { appPorts } from "@/infra/app-ports";
import { openFeatureProvider } from "@/infra/flags/openfeature";

export const server = await createNextServer({
  ports: appPorts,
  providers: [
    createOpenFeatureFlagsProvider({
      provider: openFeatureProvider,
      domain: "app",
    }),
  ],
  context: appContextBlueprint,
});

The provider contributes ctx.ports.flags and ctx.ports.openFeature as an escape hatch for advanced OpenFeature operations.

Testing

Use createMemoryFlags(...) or createStaticFlags(...) for tests:

import { createMemoryFlags } from "@beignet/core/flags";
import { billingFlags } from "@/features/billing/flags";

const flags = createMemoryFlags();
flags.set(billingFlags.newCheckout, true);

createTestPorts(...) includes an in-memory flags port by default, so most use-case tests only need to override the flag values they exercise.

Devtools

When devtools are installed before a flags provider, evaluations, provider errors, explicit exposures, and tracking events appear under the Feature flags watcher. Private attributes are redacted from instrumentation details.