Writing a Custom OpenFeature Provider

This how-to is part of OpenFeature Provider Architecture. It addresses a specific gap: your flag backend — a homegrown gRPC service, an internal config store, a Kubernetes ConfigMap watcher — has no published OpenFeature provider, so your application code would otherwise couple directly to that backend’s proprietary API. Writing a provider once isolates every call site behind the standard interface and means the server-side SDK treats your backend exactly like any commercially supported flag service.

The outcome of this how-to: a working TypeScript provider class that resolves boolean, string, number, and object flags from a custom backend, fires the correct lifecycle events, and passes the OpenFeature conformance suite.

getBooleanValue sequence through a custom provider A call to getBooleanValue on the OpenFeature client flows through the hook before phase, the custom provider's resolveBooleanEvaluation, the backend HTTP call, and back through hook after before returning to the caller. Application OF Client Custom Provider Flag Backend getBooleanValue(key, default, ctx) hook: before resolveBooleanEvaluation(key, ctx) GET /flags/{key}?ctx=… { variant, value, reason } ResolutionDetails hook: after boolean value
A getBooleanValue call travels through the hook before phase, the custom provider's resolver, and the backend HTTP call before returning a typed value to the caller via hook after.

Prerequisites

Step-by-Step Implementation

Step 1 — Implement the Resolver Methods

The provider interface requires one resolve*Evaluation method per flag type. Each method takes a flagKey string, a defaultValue of the appropriate type, and an EvaluationContext, and must return a ResolutionDetails<T> — never throw for a missing or wrong-typed flag, but set errorCode and return the default instead.

import {
  Provider,
  ResolutionDetails,
  EvaluationContext,
  StandardResolutionReasons,
  ErrorCode,
  TypeMismatchError,
  FlagNotFoundError,
} from '@openfeature/server-sdk';
import { BackendClient, BackendFlag } from './backend-client';

export class CustomFlagProvider implements Provider {
  readonly metadata = { name: 'custom-flag-provider' };
  private backend: BackendClient;

  constructor(baseUrl: string, apiKey: string) {
    this.backend = new BackendClient(baseUrl, apiKey);
  }

  async resolveBooleanEvaluation(
    flagKey: string,
    defaultValue: boolean,
    context: EvaluationContext,
  ): Promise<ResolutionDetails<boolean>> {
    return this.resolveTyped(flagKey, defaultValue, 'boolean', context);
  }

  async resolveStringEvaluation(
    flagKey: string,
    defaultValue: string,
    context: EvaluationContext,
  ): Promise<ResolutionDetails<string>> {
    return this.resolveTyped(flagKey, defaultValue, 'string', context);
  }

  async resolveNumberEvaluation(
    flagKey: string,
    defaultValue: number,
    context: EvaluationContext,
  ): Promise<ResolutionDetails<number>> {
    return this.resolveTyped(flagKey, defaultValue, 'number', context);
  }

  async resolveObjectEvaluation<T extends object>(
    flagKey: string,
    defaultValue: T,
    context: EvaluationContext,
  ): Promise<ResolutionDetails<T>> {
    return this.resolveTyped(flagKey, defaultValue, 'object', context) as Promise<ResolutionDetails<T>>;
  }

  private async resolveTyped<T>(
    flagKey: string,
    defaultValue: T,
    expectedType: string,
    context: EvaluationContext,
  ): Promise<ResolutionDetails<T>> {
    let flag: BackendFlag;
    try {
      flag = await this.backend.getFlag(flagKey, context);
    } catch (err: unknown) {
      if ((err as Error).message.includes('NOT_FOUND')) {
        throw new FlagNotFoundError(`Flag not found: ${flagKey}`);
      }
      return {
        value: defaultValue,
        reason: StandardResolutionReasons.ERROR,
        errorCode: ErrorCode.GENERAL,
        errorMessage: (err as Error).message,
      };
    }

    if (typeof flag.value !== expectedType) {
      throw new TypeMismatchError(
        `Flag ${flagKey} is type ${typeof flag.value}, expected ${expectedType}`,
      );
    }

    return {
      value: flag.value as T,
      variant: flag.variant,
      reason: flag.targeting_matched
        ? StandardResolutionReasons.TARGETING_MATCH
        : StandardResolutionReasons.DEFAULT,
    };
  }
}

Map every error the backend can return to an OpenFeature errorCode. FlagNotFoundError and TypeMismatchError are thrown (the SDK catches and wraps them); general backend errors are returned as ResolutionDetails with reason: ERROR so the caller still gets the safe default.

Step 2 — Map Backend Responses to ResolutionDetails

Your backend almost certainly uses its own field names and reason vocabulary. The mapping step is where you translate them:

// backend-client.ts — thin adapter over the custom HTTP API
export interface BackendFlag {
  key: string;
  value: boolean | string | number | object;
  variant: string;          // e.g. "on", "off", "v2"
  targeting_matched: boolean;
  source: 'cache' | 'live';
}

// Translate backend reason strings to OpenFeature standard reasons
function mapReason(flag: BackendFlag): string {
  if (flag.source === 'cache') return StandardResolutionReasons.CACHED;
  if (flag.targeting_matched)  return StandardResolutionReasons.TARGETING_MATCH;
  return StandardResolutionReasons.DEFAULT;
}

The flagMetadata field is the right place for backend-specific extras (shard ID, config version, evaluation timestamp) that don’t fit the standard fields. Hooks and telemetry can read flagMetadata without polluting the typed interface:

return {
  value: flag.value as T,
  variant: flag.variant,
  reason: mapReason(flag),
  flagMetadata: {
    configVersion: flag.config_version,
    evaluationShard: flag.shard_id,
  },
};

Step 3 — Wire Initialize, Shutdown, and Readiness Events

The SDK calls initialize once after registration and onClose (shutdown) when the application terminates. Fire PROVIDER_READY when your backend connection is established and the initial rule set is loaded; fire PROVIDER_ERROR if that fails. Use the events emitter the SDK provides:

import { OpenFeatureEventEmitter, ProviderEvents } from '@openfeature/server-sdk';

export class CustomFlagProvider implements Provider {
  // ...previous fields...
  events = new OpenFeatureEventEmitter();
  private pollTimer?: NodeJS.Timer;

  async initialize(context?: EvaluationContext): Promise<void> {
    try {
      await this.backend.connect();
      await this.backend.fetchAll();        // warm the local rule set
      this.startBackgroundPoll();
      this.events.emit(ProviderEvents.Ready, { providerName: this.metadata.name });
    } catch (err) {
      this.events.emit(ProviderEvents.Error, {
        providerName: this.metadata.name,
        message: (err as Error).message,
      });
      throw err;   // let setProviderAndWait surface the failure
    }
  }

  async onClose(): Promise<void> {
    clearInterval(this.pollTimer);
    await this.backend.disconnect();
  }

  private startBackgroundPoll() {
    this.pollTimer = setInterval(async () => {
      try {
        const changed = await this.backend.fetchAll();
        if (changed) this.events.emit(ProviderEvents.ConfigurationChanged, {
          providerName: this.metadata.name,
          flagsChanged: changed,
        });
      } catch {
        this.events.emit(ProviderEvents.Stale, { providerName: this.metadata.name });
      }
    }, 30_000);
  }
}

The polling vs streaming guide covers when to replace this background poll with a streaming connection. Emit PROVIDER_STALE when the poll fails so operator dashboards and health checks reflect degraded freshness rather than silently serving a stale rule set.

Step 4 — Add Hooks for Telemetry

Hooks are independent of the provider; they are registered on the API or client by the application, not inside the provider. However, the provider can ship a recommended hook as a companion export:

// exported alongside the provider for callers to opt in
import { Hook } from '@openfeature/server-sdk';

export const customProviderMetricsHook: Hook = {
  after(hookCtx, details) {
    metrics.increment('flag.evaluation', {
      key: hookCtx.flagKey,
      variant: details.variant ?? 'default',
      reason: details.reason,
      provider: hookCtx.providerMetadata.name,
    });
  },
  error(hookCtx, err) {
    metrics.increment('flag.evaluation.error', {
      key: hookCtx.flagKey,
      errorCode: err.message,
    });
  },
};

// Application usage:
OpenFeature.addHooks(customProviderMetricsHook);
await OpenFeature.setProviderAndWait(new CustomFlagProvider(url, apiKey));

The evaluation context enrichment guide covers how to sanitize sensitive attributes in a before hook before they reach the provider’s resolver.

Verification Step

Run the OpenFeature provider conformance suite. It exercises every resolver method, error code path, and lifecycle event using a standard gherkin harness:

# Start your backend stub (or use a Docker fixture)
docker run --rm -p 9090:9090 ghcr.io/yourorg/flag-backend-stub:latest

# Run the conformance harness against your provider
npx @openfeature/test-harness \
  --provider ./dist/custom-flag-provider.js \
  --backend-host localhost:9090

# Smoke-check a live evaluation
node -e "
const { OpenFeature } = require('@openfeature/server-sdk');
const { CustomFlagProvider } = require('./dist/custom-flag-provider');
(async () => {
  await OpenFeature.setProviderAndWait(new CustomFlagProvider(process.env.BACKEND_URL, process.env.API_KEY));
  const c = OpenFeature.getClient();
  const v = await c.getBooleanValue('checkout.payments.express-pay', false, { targetingKey: 'smoke-01' });
  console.assert(typeof v === 'boolean', 'expected boolean');
  console.log('smoke ok, value:', v);
})();
"

Every conformance step that fails identifies exactly which part of the interface contract your implementation violates. Fix those before registering the provider in production services.

Gotchas & Edge Cases

Troubleshooting & FAQ

The provider passes the smoke test but returns defaults for flags that definitely exist.

Check the errorCode field on the ResolutionDetails your resolver returns. If it is FLAG_NOT_FOUND, your getFlag backend call is using a different key format than the flags registered in the backend — confirm that namespace.service.feature keys are passed verbatim rather than being transformed. If errorCode is TYPE_MISMATCH, the backend is returning the flag value as a string (e.g., "true") but the caller is requesting it as a boolean.

The conformance suite passes but production evaluations are sometimes stale.

The background poll interval (30 s in the example) is the upper bound on how stale the local rule set can be. If your use case includes kill switches or fast canary rollouts, tighten the interval or replace polling with a streaming connection (see the polling vs streaming guide). Also confirm that PROVIDER_STALE is firing when the poll fails — silent poll failures leave the rule set frozen at the last successful fetch.

How do I test the provider without a running backend?

Register an InMemoryProvider from the OpenFeature SDK for unit tests and reserve the custom provider for integration tests that need the real backend. This avoids running a backend stub in every developer environment while still exercising the full provider code path in CI.