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.

OpenFeature layered architecture Application code calls the OpenFeature API which delegates to a client; the client invokes hooks, then the provider, which talks to the flag backend. Application Code client.getBooleanValue("...semantic-rerank", false, ctx) OpenFeature API / Client type-safe evaluation · default values · domain scoping · event emitter Hooks before / after error / finally logging · traces Provider resolveBooleanEvaluation · resolveStringEvaluation · init · shutdown · events Flag Backend flagd · LaunchDarkly · Unleash · custom gRPC service · …
OpenFeature separates the application call (API/Client) from vendor-specific resolution (Provider); hooks intercept each evaluation for telemetry without touching application code.

Prerequisites

What a provider registration depends on Five prerequisites: the OpenFeature SDK, a provider package for your backend, that backend reachable from every replica, a consistent flag-key schema, and an observability sink for hook telemetry. OpenFeature SDK server, ≥ 1.x Provider package flagd / vendor Backend reachable from each replica Key schema namespace.svc.feat Observability sink for hook telemetry
A provider registration is only as reliable as these five inputs — a missing observability sink hides exactly the evaluation errors the abstraction is meant to surface.

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.

The ResolutionDetails return contract A provider resolve call returns a struct carrying value, variant, reason, and — when reason is ERROR — an error code and message, plus optional flag metadata; the application reads value while hooks read the whole struct. resolve*Evaluation provider method ResolutionDetails value · the typed flag value variant · "on" / "off" / "v2" reason · TARGETING_MATCH / DEFAULT / ERROR errorCode · set when reason = ERROR flagMetadata · optional provider extras app reads value hooks read whole struct
Every resolve call returns a full struct, not a bare value — the application trusts 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.

The trade-off the provider abstraction makes A balance: the OpenFeature side gains portability, testability, and a standard hook ecosystem; the vendor-native side gains day-one feature access and full proprietary targeting, at the cost of lock-in. OpenFeature provider + swappable, one call-site interface + in-memory provider for tests + standard hooks across backends − feature lag until provider maps it Vendor-native SDK + day-one access to every vendor API + full proprietary targeting model − high lock-in, migration touches all − must fake SDK internals in tests
Portability and testability on one side, day-one vendor features on the other — choose the provider abstraction unless a proprietary targeting model forces the native SDK.

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.

Provider wiring sequence Register and await readiness, scope named clients to domains, add global telemetry hooks, and subscribe to provider ready, error, and stale events. 1 · Register + wait PROVIDER_READY 2 · Scope clients per domain 3 · Add hooks spans + metrics 4 · Handle events health + alerts
The wiring order matters: readiness gates traffic, domain scoping enables per-backend routing, hooks centralize telemetry, and events drive health and alerting.

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

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.

Two ways to verify a provider The conformance harness runs gherkin scenarios against a live provider, while swapping in the in-memory provider gives deterministic fixture-driven tests with no network. Conformance harness gherkin scenarios against a live provider proves spec compliance In-memory swap fixture variant map no network, no mocks deterministic unit tests
Verify spec compliance with the conformance harness against the real provider, and unit-test call sites by swapping in the in-memory provider.

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.

Provider init connection storm versus jittered startup Without jitter, 100 pods open backend connections at the same instant and spike the connection budget; with startup jitter the connections spread across a window and stay under budget. No jitter 100 conns at once → spike Jittered startup spread → under budget
Jittering provider initialization spreads the backend connection storm across a window so a synchronized rollout does not exhaust the connection 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.

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.