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.
The abstraction earns its keep the first time you need to change something that would otherwise be a cross-cutting rewrite: migrating from a hosted vendor to self-hosted flagd, running a shadow backend to validate a migration, or writing a test that must return a deterministic variant without a network call. Because every call site speaks the same typed interface — getBooleanValue, getStringValue, and friends — those changes collapse into a single registration line at startup instead of a search-and-replace across the codebase. The cost you pay for that leverage is one indirection layer and a small amount of discipline about where evaluation logic lives, both of which this guide makes concrete.
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.
A useful way to hold the boundary in your head: the specification is deliberately narrow. It standardizes the evaluation surface — the request, the resolution, the lifecycle events, and the hook points — and nothing else. It does not standardize how flags are authored, how rules are stored, how a rollout percentage is bucketed, or what a variant means to your business. Those remain the provider’s and the backend’s concern. That narrowness is what makes the abstraction portable: two providers that disagree about everything internally can still be swapped for one another as long as both honor the same ResolutionDetails contract. When you evaluate a provider for adoption, the only question that matters for portability is whether it faithfully maps its backend’s behavior onto that contract — including the error codes, which are the part providers most often get wrong.
Prerequisites
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.
These five are not peers — they form a strict call chain, and the order is load-bearing. The API is a process-global singleton, so registering a provider or adding a global hook mutates shared state that every client in the process observes; that is a feature when you want uniform telemetry and a hazard when two libraries in the same process each try to own the global provider. The client is the only object application code should hold, and it is cheap to create — there is no connection pool or heavyweight state behind getClient, so you can create one per module without cost. Context can be supplied at three levels that merge in a defined precedence: global (set once on the API), client (set on a named client), and invocation (passed to the evaluation call). Invocation context wins on key collisions, client context is next, and global is the base — which is exactly why request-scoped attributes like targetingKey belong at the invocation level and never in the global bag, where they would leak across requests.
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.
That contract has a subtle consequence worth internalizing: the default value is not a fallback of last resort, it is a first-class part of your flag’s behavior specification. Because any error path returns the default silently, the default you pass at each call site defines what your system does during a flag-backend outage. Passing false for a kill-switch that gates a risky feature means the feature stays off when the provider is unreachable — the safe posture. Passing true for a flag that gates a critical payment path means an outage disables payments — almost never what you want. Treat the default argument as an explicit answer to the question “what should happen if the provider disappears?”, and choose it per flag rather than reflexively defaulting to false. The readiness gate matters for the same reason: without setProviderAndWait, the window between process start and PROVIDER_READY is a window in which every flag silently evaluates to its default, and on a fast-booting service that window can swallow the first several seconds of production traffic.
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.
The one field you should learn to read in production is reason. It is the difference between “the flag returned false because a targeting rule matched and chose the off variant” and “the flag returned false because the provider errored and fell back to the default” — two outcomes that look identical if you only log the value. A reason of ERROR paired with an errorCode of FLAG_NOT_FOUND almost always means a drift between the flag key in your code and the key in the backend — a rename that shipped in one place but not the other. A reason of STALE (or a CACHED value long after you expected a fresh one) points at the sync transport, not the provider logic. Because these distinctions are invisible at the value level, the after-hook that records reason and errorCode as span attributes is not optional instrumentation — it is how you tell a working rollout apart from a silently degraded one. When you emit these to your metrics pipeline, a sudden rise in the ratio of ERROR and DEFAULT reasons is the earliest signal that a provider is unhealthy, often before the health check flips.
value and its default, while hooks consume reason and errorCode for telemetry.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.
There is a middle path that the table understates and that many teams land on in practice: adopt the OpenFeature interface at every call site, but reach for the vendor’s native SDK only in the narrow places that genuinely need a proprietary feature — experiment attribution, a bespoke segment API, or streaming diagnostics the provider does not expose. Because the two are not mutually exclusive within a process, you keep the portable interface for the 95% of evaluations that are plain flag reads and pay the lock-in cost only on the handful of call sites that truly require it. The mistake is the inverse: adopting the vendor SDK everywhere “to keep options open,” which guarantees that every future backend change is a full rewrite. Note also that “feature lag” is a real but shrinking cost — the provider ecosystem for mature backends tracks vendor releases closely, so the lag is measured in weeks for common features, not the years that “abstraction always trails” folklore implies. Weigh it against the concrete, recurring cost of vendor-specific error handling and test doubles, which you pay on every sprint rather than once at migration.
Step-by-Step Implementation
The four steps move from a single blocking registration to a fully observable provider: register and wait for readiness, scope clients per domain, attach telemetry hooks once, and wire the provider’s lifecycle events to health checks and alerts.
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.
A second event to handle deliberately is PROVIDER_CONFIGURATION_CHANGED, which some providers emit when the rule set is updated in place. If you cache evaluation results yourself — see distributed caching for flag evaluations — that event is your cue to invalidate, because a value that was correct a moment ago may now be stale even though the provider is perfectly healthy. And when you set healthCheck.setUnhealthy on PROVIDER_ERROR, be deliberate about whether that should remove the pod from the load balancer. Because the SDK still serves safe defaults during an error, a pod with a broken provider is often more available than a pod being cycled — pulling it from rotation can turn a degraded-but-serving fleet into a thundering-herd restart. For most services the right posture is to alert on PROVIDER_ERROR but keep serving defaults, and reserve hard-unhealthy for the case where defaults are genuinely unacceptable.
Gotchas & Edge Cases
- The default argument is evaluated eagerly. In languages where the default is an expression (
getObjectValue('...', buildExpensiveDefault(), ctx)), that expression runs on every call, even when a real value is returned. For a hot path, hoist the default to a constant instead of constructing it inline. - Context set globally leaks across requests. The global and client context layers persist for the process lifetime. Putting a
targetingKeyor any per-user attribute there means every subsequent evaluation in the process inherits it until overwritten — a classic source of “user A saw user B’s variant” bugs. Keep request-scoped attributes at the invocation level only. setProvider(withoutAndWait) does not block, and swallows init failures. The non-blocking form returns immediately and surfaces failure only through a laterPROVIDER_ERRORevent. If you use it, you must subscribe to that event or you will never learn the provider failed to start.- Hooks run in registration order, and a throwing
beforehook aborts the evaluation. An exception in abeforehook short-circuits to the error path and returns the default — so a buggy telemetry hook can silently disable a flag for the whole fleet. Wrap hook bodies defensively and never let instrumentation throw. - Named-client typos fail open, not loud.
getClient('checkut')(misspelled) does not error — it returns a valid client bound to whatever provider that unknown domain resolves to, usually the default provider. The evaluation “works” but routes to the wrong backend. Centralize domain names as constants rather than string literals scattered across call sites. - Multi-provider processes share one global hook list. Hooks added with
OpenFeature.addHooksfire for every client regardless of domain or provider. If a hook assumes a single backend’s metadata shape, it will misbehave the moment a second provider is registered. Scope backend-specific hooks to a client instead of the API.
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.
One overhead that surprises teams is context copying. Every evaluation merges the global, client, and invocation context into a single map before the provider sees it, and if your invocation context is large — a deeply nested object with dozens of attributes — that merge and copy runs per call. When you are evaluating the same flag many times inside one request, resolve it once and reuse the value rather than paying the merge cost repeatedly. A related trap is getObjectValue for large JSON payloads: the SDK deserializes and, in most implementations, deep-clones the object on every call so that a caller cannot mutate cached provider state. For a multi-kilobyte config object read in a tight loop, that cloning dominates the evaluation cost far more than any targeting logic. Cache the resolved object at the request scope. Finally, remember that the provider’s own transport is usually the real latency floor: a provider that resolves against a local flagd sidecar over a Unix socket is sub-millisecond, while one that makes a network round-trip per evaluation is bound by that round-trip no matter how thin the OpenFeature layer is — which is the whole argument for streaming synchronization that keeps the rule set local and turns every evaluation into an in-process lookup.
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.
What is the difference between a reason of DEFAULT and a reason of ERROR?
Both can return the value you passed as the default argument, but they mean opposite things. DEFAULT means the provider evaluated the flag successfully and no targeting rule matched, so the flag’s configured default variant applied — a healthy, expected outcome. ERROR means resolution failed and the SDK fell back to the argument default, with an errorCode explaining why. If you only log the value you cannot tell these apart, which is why production telemetry should record reason on every evaluation.
Should I create one OpenFeature client per request or reuse a shared client?
Reuse a shared client — clients are cheap, stateless handles with no connection pool behind them, so there is nothing to gain from per-request creation and a small allocation cost to pay. Create one named client per logical domain at module load and hold it for the process lifetime. Pass the request-scoped evaluation context to each getXValue call instead; the context, not the client, is what should vary per request.
Can two libraries in the same process each register their own OpenFeature provider?
Only if they use distinct named domains. The default (unnamed) provider is process-global, so two libraries both calling OpenFeature.setProvider() will clobber each other — last write wins. Library code that needs its own backend should register under a unique domain name and expose a client scoped to that domain, never touch the default provider, and document the domain so the host application does not collide with it.
How do I test targeting logic without standing up a real flag backend?
Swap in InMemoryProvider with a fixture map that defines each flag’s variants and default. It honors the full evaluation and hook lifecycle, so your call sites, hooks, and reason handling all run exactly as in production, but resolution is deterministic and requires no network. For targeting-rule coverage specifically, give the in-memory flag a context-evaluator function so a supplied targetingKey selects a variant, letting you assert that a given context yields the expected variant.
Does OpenFeature cache evaluation results, and can it serve a stale value?
The core SDK does not cache — each getXValue call reaches the provider. Caching, if any, lives inside the provider or the transport it uses, which is why a reason of CACHED or a PROVIDER_STALE event comes from that layer, not the API. If you add your own caching in front of evaluations, invalidate it on PROVIDER_CONFIGURATION_CHANGED and PROVIDER_STALE, and remember that a cached value can outlive a targeting change even while the provider reports healthy.