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.
Prerequisites
@openfeature/server-sdk≥ 1.x)namespace.service.featureschema per your flag taxonomy@openfeature/test-harness) for conformance verification
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
- Never throw from a resolver for a general backend error. Throwing bypasses the SDK’s default-value guarantee and may crash a call site. Only throw
FlagNotFoundErrororTypeMismatchError— the SDK catches those and maps them toerrorCode; all other errors should be returned asResolutionDetailswithreason: ERROR. initializemust be idempotent. The SDK may call it more than once during reconnect cycles. Guard against double-connecting or double-polling by checking internal state at the top ofinitialize.- Context is a snapshot, not a live object. Do not hold a reference to the
EvaluationContextpassed to a resolver. Build the backend request payload from it synchronously, then let the reference be GC’d. Holding context references across async ticks has caused subtle targeting bugs in production providers.
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.