Feature flags sound simple: check a boolean, branch on it. But the moment you adopt them at any real scale, your
production environment depends on an external service for something that sits on nearly every request path. The
API is trivial, isEnabled('some-flag'), but the operational reality behind that single call is not. The provider is a
network dependency. It can be slow. It can be unreachable. It can go down entirely. And when it does, your feature
flag check, a piece of infrastructure meant to make your releases safer, can become the thing that takes the entire
service down.
This effort started with a decision that had nothing to do with flags: we are moving to trunk-based development. Merging to
main continuously, without long-lived feature branches, only works if you have a way to ship unfinished or risky code
dark; and be able turn it on, separately from the deployment. That’s what feature flags are for, but “we need feature flags”
doesn’t tell you which flag system to build on, and we didn’t want to build one ourselves or lock ourselves into a
single vendor’s SDK. Both of those turn out to hinge on who owns a flag, which is a section of its own below. (That
move to trunk-based development is its own story, and one we’ll get into in a follow-up post.)
That’s what led us to OpenFeature : a vendor neutral, open standard for flag evaluation, backed by the CNCF rather than any one provider. It solves the part we didn’t want to solve ourselves: a common interface regardless of which flag backend sits behind it. But adopting the standard didn’t answer the questions that came right after: do we inject it instead of reaching for a global? What happens to our checkout flow if the flag provider is slow? And, most importantly, how do we test the failure case?
Flipster is our answer to those three questions, written at the start of adopting OpenFeature and trunk-based development together, not after years of running either.
There’s a second thing worth naming: Flipster is a product of Spec-Driven Development
,
a practice we have been running internally for over a year now. This wasn’t us trying it out for the first time; it was
the discipline showing up in a public artifact. We wrote the specification (the behavior table shown below, the guarantees around defaults, the failure
semantics) before implementation, and let the spec drive what got built. The .aidlc directory in the repo is a
visible trace of that process. It’s part of why the README reads the way it does: the behavior was decided on paper
first, which made it possible to be precise about things like “an evaluation never throws because a provider is unwell”
instead of discovering that guarantee informally over time.
Two owners, one switch
A feature flag has two lifecycles: Ownership of the flag and ownership of its exposure. Each of them belong to different people.
Engineering owns the flag. We introduce it when the work starts; we are responsible for the code on both sides of it, and more importantly we are responsible for removing it. A flag that outlives its purpose is a permanent branch in the code: a small compounding tax on every change that touches it.
Product owns the exposure. When the feature gets activated, for which accounts, in what order, whether it widens or gets pulled back. None of those are engineering calls, and none of them should consume engineering capacity. We used to be involved because the deployment was controlling the flags and the deployment was ours.
Separating those two concerns is what removes this unnecessary link. Engineering decides whether the switch exists and when it stops existing. Product decides in which state it is and for whom. Neither has to negotiate with the other’s calendar to do its own job.
Both owners have to be in the room for the build-versus-buy question. A flag system that satisfies only engineering is a weekend’s work and useless the first time product wants to widen a rollout without filing a ticket. One that satisfies only product gets picked on the demo, and nobody asks whether it speaks OpenFeature until its SDK is already at every call site. Both halves have to be answered by the same decision, which is most of why we neither wrote our own nor shopped on features alone.
The same pair is the reason the switch has to survive a change of provider. Whatever backend we run today, those two lifecycles outlive it: engineering will still introduce and remove flags, product will still decide exposure and timing. Neither of those should have to be renegotiated because we moved to a different vendor. That is the practical argument for a neutral API rather than a vendor’s SDK; a provider swap stays a swap, instead of turning into a conversation about who owns what.
Three problems, one library
Once flags are owned this way, the surrounding tooling has to earn that trust.
Problem one: the SDK wants to be a singleton
OpenFeature’s idiomatic entry point is a process global. That’s a reasonable default for the SDK itself, but it’s an awkward fit for any codebase that takes dependency injection seriously. Global state resists testing, resists swapping implementations per environment, and quietly couples your domain code to a specific initialization order.
Flipster’s fix here is almost boring: it wraps the SDK behind a narrow interface you inject like anything else. Domain
code depends on a FlagEvaluator, not on a global. That’s it. It’s not a clever abstraction, it’s the absence of one.
Problem two: a flag check is a network call on your hot path
This is the one that actually hurts in production. A flag provider is, almost always, a service you talk to over the
network. Every isEnabled() call is a request that can be slow or can fail, and if that call sits in a hot path (say,
checkout, or auth), a degraded flag provider can degrade everything downstream of it. Even though the flag itself was
never the point of the request.
The standard answer to “a dependency might be slow or down” is a circuit breaker, and that’s what Flipster puts in front of the provider. Flipster doesn’t implement its own breaker logic, it defines a small port and ships an adapter over the existing Ganesha library, so you get a proven implementation rather than a bespoke one.
But the breaker alone isn’t the interesting part. The interesting part is what happens before the breaker even trips, because a single failed request and a fully open circuit are different situations, and conflating them is where a lot of flag handling code goes wrong.
In fact, they need different responses. One failed evaluation should be absorbed on the spot: you serve something sensible and carry on, because a provider that hiccups once is still a provider. An open circuit is a different claim entirely, that this provider is known to be unwell, and the right response is to stop calling it at all. Flipster does both and in that order. The declared default answers each individual failure the moment it happens, and the breaker, once tripped, stops the wasted network attempts. Watch a real provider get killed underneath it and you see the defaults start appearing several requests before the circuit actually opens.
Problem three: defaults that disagree, and failure paths nobody can test
Here’s the pattern we wanted to avoid before it ever had the chance to happen: a feature flag call with a fallback
value passed at the call site (getBooleanValue('new-checkout', false)), repeated at a few more call sites, each with
its own guess at the right default. A few months later, nobody remembers which fallback is the ‘official’ one, and nobody
has a reliable way to force the provider into a degraded state to check what actually happens. We hadn’t lived through
that yet, we just didn’t want to build it that way in the first place.
Flipster addresses this with a single idea: declare defaults once, separately from call sites. withDefaults() is
the one place in your codebase a flag’s fallback value lives. Call sites don’t carry a default; they can’t disagree
with each other, because there’s nothing to disagree about.
That makes withDefaults() more than a fallback map. It is the list of flags your code is allowed to ask about at all:
a flag missing from it throws even when the provider is perfectly healthy and would have answered. Adopting Flipster
means accepting that every flag gets declared, not just the ones you expect to fail.
This produces a small but deliberate behavior table:
| Situation | Value served |
|---|---|
| Provider answers | the live value, untouched |
| Provider errors, flag declared | the declared default |
| Circuit open | the declared default (provider not even called) |
| Flag not declared | throws: never silently false |
The middle two rows are the distinction from the previous section, written down. An error while the circuit is still closed costs a full round trip and then falls back; an open circuit skips the call altogether. Same value served, different amount of your request budget spent getting there.
That last row is the one people notice first, and argue about. Flipster intentionally does not let an undeclared flag
silently evaluate to false. A typo’d flag key that quietly reads as “feature disabled” is indistinguishable from a
feature someone deliberately turned off, and nobody investigates a flag that looks correctly configured. So it throws,
with a message that suggests the key you probably meant. Declarations are also validated when the stack is built, not
at first use, so a malformed defaults map fails during deployment instead of surprising someone during an incident.
And because failure behavior is now fully deterministic (a declared default, injected doubles, a controllable breaker),
the failure paths become ordinarily testable, instead of the kind of thing you only find out about in production. That
determinism is easier to see than to describe. The diagram below traces a single isEnabled() call through both checks:
whether the flag is declared, and whether the circuit is open; and shows exactly which of the four rows in the table
above it lands on.
in withDefaults?} B -- No --> C["Throw
(suggests likely key)"] B -- Yes --> D{Circuit breaker
open?} D -- "Yes (provider known unwell)" --> E["Return declared default
— provider not even called —"] D -- No --> F["Call provider over network"] F --> G{Provider
responds?} G -- Success --> H["Return live value"] G -- "Error / timeout" --> I["Return declared default
(full round trip spent)"] I --> J{Failure threshold
reached?} J -- Yes --> K["Breaker trips open"] J -- No --> L["Breaker stays closed"] style C fill:#f8d7da,stroke:#c0392b style E fill:#fff3cd,stroke:#b7891a style I fill:#fff3cd,stroke:#b7891a style H fill:#d4edda,stroke:#2e7d32 style K fill:#f8d7da,stroke:#c0392b
That determinism only helps if you can actually exercise it in a test, so Flipster ships its own test doubles in the
runtime package, no extra dependency to pull in. An InMemoryProvider stands in for a real backend, a
RecordingProvider wraps any provider and tracks how many times it was actually called, a ThrowingProvider simulates
a hard failure, and a ControllableCircuitBreaker lets a test pin the circuit open directly instead of trying to
manufacture enough failing requests to trip a real threshold. That last one matters more than it sounds: without it,
“test what happens when the circuit is open” means fighting your breaker’s timing and thresholds just to get into the
state you actually wanted to assert on.
Put together, all three fixes are one builder call at the edge of the application and an injected dependency everywhere else:
use Epignosis\Flipster\Flipster;
$flags = Flipster::for($provider) // any OpenFeature provider
->withDefaults([
'new-checkout' => false,
'checkout-variant' => 'control',
'rate-limit-rpm' => 100,
])
->withBreaker($breaker, 'flags')
->evaluator();
// Domain code depends on FlagEvaluator, and on nothing else.
if ($flags->isEnabled('new-checkout')) {
// ...
}
Note what the call site does not contain: a default value, a try/catch, or any hint that a network is involved.
What we deliberately left out
Flipster does not cache. That was a conscious decision, not an oversight: the provider you choose already owns the flag data’s lifecycle and its invalidation signals, and a cache bolted on from the outside can only guess at correctness with a TTL. Caching is a job for the layer that actually knows when the data changed.
We also didn’t build another flag evaluation API. Flipster composes with OpenFeature; it doesn’t compete with it. If you want per-call defaults instead of the declared-once model, the underlying OpenFeature client is still right there.
The shape of the fix
None of these three problems are novel, and none of the fixes are exotic: DI over globals, a circuit breaker over a flaky dependency, single sourced defaults over scattered ones. That’s sort of the point. This is early. Flipster is new, born directly out of adopting trunk-based development and OpenFeature together, and built the way we build everything now, spec first. We’d rather get this resilience layer right once, in the open, before we’ve written “make the flag provider safe to depend on” glue code in five different places and had to reconcile it later.
The quick facts:
- 344 tests, 654 assertions, 99+% coverage: the failure paths are covered, not just the happy one
- MIT licensed, and public because none of the problems it solves are specific to us
- Batteries included: a containerized toolchain where Docker is the only prerequisite, CI across PHP 8.1 through 8.5, runnable examples, and documentation that covers the traps rather than just the API
Flipster is on GitHub at epignosis/flipster , MIT licensed, and installable via Composer:
composer require epignosis/flipster
If you’re already on OpenFeature and just want the resilience layer without adopting anything else, docs/migrating.md
in the repo walks through adopting it incrementally in a codebase that’s already using the SDK directly.
