OpenFeature Provider Architecture
This guide is part of the Backend Evaluation & Server-Side SDKs series. OpenFeature standardizes how application code requests a flag value — and how the runtime behind it resolves one — so you can swap flag backends without rewriting the call sites that use them. Understanding the provider model is the prerequisite for every other topic in server-side evaluation: the server-side SDK init lifecycle, the evaluation context that targeting rules consume, and the flag sync transport that keeps the provider’s rule set fresh.
Problem Framing: What the Provider Layer Solves
Without a standard abstraction every service that evaluates flags depends directly on a specific vendor SDK: its API surface, its error types, its initialization contract, and its telemetry hooks. Replacing or testing a flag backend means touching every call site. The OpenFeature specification draws a single boundary between what your application asks for (a typed flag value) and how a provider answers (a resolution with a variant, a reason, and an error code). Your application code calls one stable interface; the provider behind it implements vendor-specific transport and rule evaluation.
This guide covers the specification model, its components, the trade-offs of adopting the provider abstraction versus a vendor’s native SDK, and how hooks add telemetry and logging without touching application code. It does not cover building a provider from scratch — see Writing a Custom OpenFeature Provider for that step-by-step how-to — or the caching layer behind the provider, covered in distributed caching for flag evaluations.
Prerequisites
@openfeature/server-sdk≥ 1.x,openfeature-sdkPython ≥ 0.4.x, or the Go SDK ≥ 1.x)namespace.service.featureschema per your flag taxonomy
Core Concept & Architecture
The Five-Component Model
The OpenFeature specification defines five concepts that interact in every evaluation:
API — The singleton entry point. You register a provider against it once per process (or per named domain) and it exposes a factory that creates clients. It also owns the global hook list.
Client — The object your application code holds. Each client can be scoped to a domain (a logical service boundary); multiple clients can coexist and share one provider or use different providers in a multi-provider setup. The client exposes typed methods: getBooleanValue, getStringValue, getNumberValue, getObjectValue, each with a required default that is returned on any error.
Provider — The adapter between the OpenFeature interface and a specific flag backend. It implements a fixed interface: initialize, shutdown, four resolve*Evaluation methods, and an event emitter that fires PROVIDER_READY, PROVIDER_ERROR, and PROVIDER_STALE. The SDK calls the provider’s methods; the provider translates them to whatever protocol its backend speaks.
Hooks — Functions that run at four points in every evaluation lifecycle: before (can mutate context), after (receives the resolved value), error, and finally. Hooks are the correct place for logging, tracing, and metrics — they keep telemetry out of both application code and the provider.
Evaluation Context — A bag of key-value attributes (user ID, tenant, plan tier, request region, etc.) passed by the application with each evaluation call. The provider uses this bag to apply targeting rules. The specification calls the primary discriminator targetingKey — everything else is free-form. See context enrichment strategies for how to assemble and sanitize this bag efficiently.
Initialization and the Readiness Contract
The provider goes through a startup sequence before it can serve evaluations. In TypeScript:
import { OpenFeature } from '@openfeature/server-sdk';
import { FlagdProvider } from '@openfeature/flagd-provider';
// Register once at startup — blocks until PROVIDER_READY or throws
await OpenFeature.setProviderAndWait(new FlagdProvider({
host: 'flagd.internal',
port: 8013,
tls: false,
}));
const client = OpenFeature.getClient('checkout');
// Safe evaluation — default false returned on any provider error
const enabled = await client.getBooleanValue(
'checkout.payments.express-pay',
false,
{ targetingKey: req.userId, tenantTier: req.account.tier }
);
setProviderAndWait resolves after PROVIDER_READY fires, so your HTTP server does not start accepting traffic until the rule set is loaded. The safe default (false) is the SDK’s contract: on PROVIDER_NOT_READY, FLAG_NOT_FOUND, or GENERAL error the client returns the default rather than throwing, so a broken provider never crashes a call site.
Resolution Details: The Provider’s Return Contract
Every resolve*Evaluation method returns a ResolutionDetails struct, not a bare value. The struct carries:
| Field | Type | Meaning |
|---|---|---|
value |
typed | The resolved flag value |
variant |
string | Which named variant was selected ("on", "off", "v2", …) |
reason |
string | Why this value was chosen (TARGETING_MATCH, DEFAULT, SPLIT, CACHED, ERROR) |
errorCode |
string | Set when reason is ERROR (FLAG_NOT_FOUND, TYPE_MISMATCH, PARSE_ERROR, etc.) |
errorMessage |
string | Human-readable detail; never exposed to end users |
flagMetadata |
map | Optional provider-specific extras (timestamp, source shard, etc.) |
Application code rarely inspects ResolutionDetails directly — it calls the typed value accessor and trusts the default. Hooks and telemetry pipelines consume the full struct.
OpenFeature vs Vendor-Native SDK: Decision Table
Choosing the provider abstraction costs you some backend-specific features in exchange for portability and testability. Be explicit about what you are giving up:
| Dimension | OpenFeature provider | Vendor-native SDK |
|---|---|---|
| API portability | One call-site interface, backend is swappable | Locked to vendor types, errors, and method names |
| Testing | Swap in an in-memory provider; no mock calls needed | Must stub or fake vendor SDK internals |
| Lock-in | Low — changing provider is a config change | High — migration touches every call site |
| Feature lag | Provider must expose new vendor features via interface extensions | Direct access to every vendor API the day it ships |
| Advanced targeting | Only what the provider maps into ResolutionDetails |
Full access to vendor-specific rule types |
| Multi-backend | Supported via aggregation providers or domain-scoped providers | Requires hand-written fan-out code |
| Hook ecosystem | Standard hooks work across all providers | Vendor-specific telemetry integrations only |
The practical rule: adopt OpenFeature when portability, testability, or hook-based telemetry matters more than day-one access to vendor-specific features. Use a vendor-native SDK only if you depend on a proprietary targeting model that a provider cannot yet surface through ResolutionDetails.
Step-by-Step Implementation
Step 1 — Register the provider at application startup
Initialize the provider before any request handling begins. Use setProviderAndWait so the readiness check is synchronous from the application’s perspective.
import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';
// In tests: swap for InMemoryProvider with a fixture map
const isTest = process.env.NODE_ENV === 'test';
const provider = isTest
? new InMemoryProvider({
'api.search.semantic-rerank': { defaultVariant: 'off', variants: { on: true, off: false }, disabled: false },
})
: new FlagdProvider({ host: process.env.FLAGD_HOST, port: 8013 });
await OpenFeature.setProviderAndWait(provider);
Pitfall: registering a provider without await means the first evaluations may fire before PROVIDER_READY. In a fast-starting service this silently returns defaults for every flag — and you never see an error.
Step 2 — Scope clients to service domains
Create a named client per logical domain rather than a single global client. Domain scoping lets you attach different hook sets per domain and — with a multi-provider setup — route evaluations to different backends.
const checkoutClient = OpenFeature.getClient('checkout');
const searchClient = OpenFeature.getClient('search');
// Each client evaluates independently; both share the registered provider
const showExpressPay = await checkoutClient.getBooleanValue(
'checkout.payments.express-pay', false, evalCtx
);
const useRerank = await searchClient.getBooleanValue(
'api.search.semantic-rerank', false, evalCtx
);
Pitfall: using one global un-scoped client in a service that owns multiple product domains makes it impossible to route different flag namespaces to different backends later without a refactor.
Step 3 — Add hooks for logging and telemetry
Attach hooks at the API level (all providers, all clients) or at a single client. The after hook receives the full ResolutionDetails and is the right place to emit spans and metrics.
import { Hook, EvaluationDetails } from '@openfeature/server-sdk';
const otelHook: Hook = {
after(hookCtx, details: EvaluationDetails<unknown>) {
const span = tracer.startSpan('feature_flag.evaluation');
span.setAttributes({
'feature_flag.key': hookCtx.flagKey,
'feature_flag.provider_name': hookCtx.providerMetadata.name,
'feature_flag.variant': details.variant ?? 'unknown',
'feature_flag.reason': details.reason,
});
span.end();
metrics.histogram('flag.evaluation.latency', Date.now() - hookCtx.startTime);
},
error(hookCtx, err) {
logger.error({ flagKey: hookCtx.flagKey, error: err.message }, 'flag evaluation error');
},
};
// Register globally — fires on every evaluation across every client
OpenFeature.addHooks(otelHook);
Pitfall: adding tracing inside application call sites (const value = await client.getBoolean(...); tracer.startSpan(...)) duplicates instrumentation across hundreds of locations and breaks consistency when you add a new client. Hooks centralize it.
Step 4 — Handle provider events for health checks and alerting
Subscribe to provider events to drive readiness probes and on-call alerts rather than polling the provider’s state manually.
OpenFeature.addHandler(ProviderEvents.Error, ({ providerName, message }) => {
logger.error({ providerName, message }, 'provider error — evaluations returning defaults');
healthCheck.setUnhealthy('feature-flags');
alerting.fire('PROVIDER_ERROR', { provider: providerName, detail: message });
});
OpenFeature.addHandler(ProviderEvents.Ready, ({ providerName }) => {
healthCheck.setHealthy('feature-flags');
logger.info({ providerName }, 'provider ready');
});
OpenFeature.addHandler(ProviderEvents.Stale, ({ providerName }) => {
// Rule set may be outdated — evaluations still work but accuracy is degraded
logger.warn({ providerName }, 'provider stale — rule set may not reflect latest config');
});
Pitfall: ignoring PROVIDER_STALE means a disconnected node silently serves an outdated rule set. Wire it to at least a warning log and, for high-stakes flags, an alert that triggers re-initialization.
Verification & Testing
Run the OpenFeature conformance suite against any provider you register:
# Run the official OpenFeature provider test harness (gherkin-based)
npx @openfeature/test-harness --provider flagd --host localhost --port 8013
# Smoke-check a live evaluation path
node -e "
const { OpenFeature } = require('@openfeature/server-sdk');
const { FlagdProvider } = require('@openfeature/flagd-provider');
(async () => {
await OpenFeature.setProviderAndWait(new FlagdProvider());
const c = OpenFeature.getClient();
const v = await c.getBooleanValue('checkout.payments.express-pay', false, { targetingKey: 'smoke-test' });
console.log('variant:', v); // must not throw; safe default acceptable
process.exit(0);
})();
"
Test provider swaps by replacing FlagdProvider with InMemoryProvider — no network needed, no mocks, deterministic fixture values.
Performance & Scale Considerations
The OpenFeature client layer itself adds microseconds of overhead: hook dispatch, context copying, and the ResolutionDetails struct allocation. At tens of thousands of evaluations per second that overhead is negligible compared to the cost of a local rule-engine lookup. The real scale concern is provider initialization: in a fleet that rolls out 100 pods simultaneously every pod opens a connection to the flag backend at startup. Stagger provider initialization with a small random jitter, and make the backend’s connection budget a deployment parameter rather than a coincidental number. Hook chains accumulate: an OpenTelemetry hook plus a logging hook plus a custom metrics hook run serially. Profile them if evaluation p99 exceeds your rule engine latency budget.
Troubleshooting & FAQ
Why do all evaluations return the default value immediately after deploy?
PROVIDER_NOT_READY is the most common cause. The provider’s initialize method did not complete before the first evaluation ran — typically because the application did not await setProviderAndWait. Add await, confirm the readiness health check passes, and re-check. A blocked egress route to the flag backend causes the same symptom with a timeout delay.
How do I run multiple providers simultaneously for different flag namespaces?
Use domain-scoped provider registration: OpenFeature.setProviderAndWait(providerA, 'checkout') and OpenFeature.setProviderAndWait(providerB, 'search'). Clients created with OpenFeature.getClient('checkout') resolve against providerA; clients in the search domain use providerB. Hooks registered globally fire regardless of domain.
Can I use OpenFeature hooks to enforce PII rules on evaluation context?
Yes — a before hook can inspect and redact context attributes before the provider sees them. This is preferable to relying on the provider to strip PII, because provider implementations vary. The approach is detailed in masking PII in evaluation context.