Migrate Quarkus Feature Flags to OpenFeature
Keep the existing quarkus-flags API, move one key to flagd, and define safe behavior when the provider is unavailable.
I started looking into feature flags in Quarkus a year ago. I tried a few ideas and wrote several drafts about what I learned. But I never published them. Each time, something made me put the topic aside. The drafts stayed in my notes and got older with every Quarkus release.
Then I found the new Quarkus Feature Flags OpenFeature extension. It felt like the right time to return to those drafts and try again. The extension lets us move a flag to a new backend and keep the existing quarkus-flags code in the application.
Why use feature flags in the first place? A feature flag is a named value that an application reads while it runs. The value can turn a feature on for a small group, send one customer to a new version, or turn a risky change off. A team can deploy the code first and release the new behavior later.
Feature flags also add another system that somebody has to run. The application keeps the flag name, its type, a default value, and the data used for targeting. A flag service stores the current values and rules. Problems start when business code calls that service directly in many places. Replacing the service later then means changing the business code too.
OpenFeature gives us one common API for reading flags. A provider connects that API to a flag service. We use flagd here. It is a small service that reads our flag file and applies the targeting rules.
The new Quarkus extension connects Flags and Flag from quarkus-flags to the OpenFeature Java SDK. Our application keeps using the same API. OpenFeature and flagd handle the flag lookup behind it.
We will switch a string flag called pricing-engine. A small quote API uses it to choose between stable and dynamic pricing. First, we move this one flag to flagd and target one tenant. Then we change the rule without restarting Quarkus. We also test the same code with the built-in in-memory provider.
This works well for an application that already uses quarkus-flags or one of its integrations. For a new application that wants to use OpenFeature directly, I would also look at the separate Quarkus OpenFeature extension family.
What you will build
The application exposes one endpoint:
GET /quotes/{tenantId}?subtotal={amount}The pricing-engine flag controls the calculation:
stableleaves the subtotal unchangeddynamicapplies a 10% discountNorthwind gets
dynamic; every other tenant getsstablethe application uses
stablewhile the flagd provider is down or still starting
Prerequisites
You need:
JDK 21
the Quarkus CLI
Podman
curlbasic Quarkus REST and CDI knowledge
about ☕️☕️
The OpenFeature adapter is still in preview. For supported production runtimes and lifecycle information, see the IBM Enterprise Build of Quarkus.
Create the application
Create an empty Maven application with JSON support or clone my large mono-repo which also has this example in it:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.37.2 \
--maven \
--java=21 \
--no-code \
--extensions='rest-jackson' \
com.ibm.developer:quarkus-openfeature-migration
cd quarkus-openfeature-migrationAdd the core quarkus-flags extension. We start with a local flag and move it later:
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags</artifactId>
<version>1.0.0</version>
</dependency>Define the flag in src/main/resources/application.properties:
quarkus.http.port=8088
quarkus.flags.runtime."pricing-engine".value=stableThe application now knows the flag name and the quarkus-flags API. Its value still comes from local Quarkus configuration.
Keep provider code out of the quote service
Create src/main/java/com/ibm/developer/pricing/Quote.java:
package com.ibm.developer.pricing;
import java.math.BigDecimal;
public record Quote(
String tenantId,
String pricingEngine,
BigDecimal subtotal,
BigDecimal discount,
BigDecimal total,
String flagOrigin) {
}The response includes flagOrigin. It shows us which provider returned the flag. Leave this field out of a real public pricing API obviously.
Create src/main/java/com/ibm/developer/pricing/PricingService.java:
package com.ibm.developer.pricing;
import java.math.BigDecimal;
import java.math.RoundingMode;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkiverse.flags.Flag;
import io.quarkiverse.flags.Flags;
import io.smallrye.mutiny.Uni;
@ApplicationScoped
public class PricingService {
private static final String PRICING_ENGINE = "pricing-engine";
private static final BigDecimal DYNAMIC_DISCOUNT_RATE = new BigDecimal("0.10");
private final Flags flags;
public PricingService(Flags flags) {
this.flags = flags;
}
public Uni<Quote> createQuote(String tenantId, BigDecimal subtotal) {
Flag.ComputationContext context = Flag.ComputationContext.of("targetingKey", tenantId);
return flags.find(PRICING_ENGINE)
.map(optionalFlag -> optionalFlag.orElseThrow())
.chain(flag -> flag.compute(context)
.map(value -> calculate(tenantId, subtotal, value.asString(), flag.origin())));
}
private Quote calculate(String tenantId, BigDecimal subtotal, String pricingEngine, String flagOrigin) {
BigDecimal normalizedSubtotal = subtotal.setScale(2, RoundingMode.HALF_UP);
BigDecimal discountRate = switch (pricingEngine) {
case "stable" -> BigDecimal.ZERO;
case "dynamic" -> DYNAMIC_DISCOUNT_RATE;
default -> throw new IllegalStateException("Unsupported pricing engine: " + pricingEngine);
};
BigDecimal discount = normalizedSubtotal.multiply(discountRate).setScale(2, RoundingMode.HALF_UP);
return new Quote(
tenantId,
pricingEngine,
normalizedSubtotal,
discount,
normalizedSubtotal.subtract(discount),
flagOrigin);
}
}This class only knows quarkus-flags. ComputationContext is part of that API too. Later, the adapter maps its special targetingKey value to the OpenFeature targeting key. It maps all other values to normal OpenFeature context fields.
Flag.compute() returns a Uni, so the whole call stays asynchronous. The provider call can block.
Create src/main/java/com/ibm/developer/pricing/QuoteResource.java:
package com.ibm.developer.pricing;
import java.math.BigDecimal;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.jboss.resteasy.reactive.RestPath;
import org.jboss.resteasy.reactive.RestQuery;
import io.smallrye.mutiny.Uni;
@Path("/quotes")
@Produces(MediaType.APPLICATION_JSON)
public class QuoteResource {
private final PricingService pricingService;
public QuoteResource(PricingService pricingService) {
this.pricingService = pricingService;
}
@GET
@Path("/{tenantId}")
public Uni<Quote> quote(@RestPath String tenantId, @RestQuery BigDecimal subtotal) {
if (subtotal == null || subtotal.signum() <= 0) {
throw new BadRequestException("subtotal must be greater than zero");
}
return pricingService.createQuote(tenantId, subtotal);
}
}Start Quarkus and call the endpoint:
./mvnw quarkus:devcurl -s 'http://localhost:8088/quotes/northwind?subtotal=100.00'The response contains "pricingEngine":"stable" and "total":100.00. Every tenant gets the local value from Quarkus configuration. Stop dev mode before the next step.
Move the flag to OpenFeature
Remove the quarkus-flags dependency. Add the OpenFeature adapter and the flagd provider:
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags-openfeature</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>flagd</artifactId>
<version>0.14.0</version>
</dependency>The adapter already includes the core quarkus-flags API and the OpenFeature Java SDK. The second dependency connects the SDK to flagd.
Replace the old quarkus.flags.runtime setting in src/main/resources/application.properties:
quarkus.http.port=8088
quarkus.flags.openfeature.pricing-engine.type=string
quarkus.flags.openfeature.pricing-engine.default-value=stableOpenFeature can read a known flag key. It cannot list all flags. This means the adapter needs the type and default value in local configuration before the key appears in Flags. Both settings are required.
Choose the default with care. If the flag is missing, flagd is down, the type is wrong, or the provider is still starting, the adapter returns stable and writes a warning to the log. It stops there. It does not check the old config flag next.
You can leave other keys under quarkus.flags.runtime and move them later. Once pricing-engine is registered with OpenFeature, OpenFeature also controls its fallback. The old property is no longer a second fallback.
Connect to flagd without blocking startup
Add the application-specific flagd settings to application.properties:
pricing.flagd.enabled=true
pricing.flagd.host=localhost
pricing.flagd.port=8013
%test.pricing.flagd.enabled=falseCreate src/main/java/com/ibm/developer/pricing/FlagdConfig.java:
package com.ibm.developer.pricing;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "pricing.flagd")
public interface FlagdConfig {
boolean enabled();
String host();
int port();
}In production, use environment variables such as PRICING_FLAGD_HOST and PRICING_FLAGD_PORT to change these values.
Create src/main/java/com/ibm/developer/pricing/FlagdLifecycle.java:
package com.ibm.developer.pricing;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import org.jboss.logging.Logger;
import dev.openfeature.contrib.providers.flagd.FlagdOptions;
import dev.openfeature.contrib.providers.flagd.FlagdProvider;
import dev.openfeature.sdk.OpenFeatureAPI;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
@ApplicationScoped
public class FlagdLifecycle {
private static final Logger LOG = Logger.getLogger(FlagdLifecycle.class);
private final FlagdConfig config;
private boolean providerRegistered;
public FlagdLifecycle(FlagdConfig config) {
this.config = config;
}
void onStart(@Observes StartupEvent event) {
if (!config.enabled()) {
return;
}
FlagdOptions options = FlagdOptions.builder()
.host(config.host())
.port(config.port())
.build();
OpenFeatureAPI.getInstance().setProvider(new FlagdProvider(options));
providerRegistered = true;
LOG.infof("Registered the flagd provider at %s:%d", config.host(), config.port());
}
void onStop(@Observes ShutdownEvent event) {
if (providerRegistered) {
OpenFeatureAPI.getInstance().shutdown();
}
}
}setProvider() starts the provider in the background. Quarkus can finish startup and use the default while the provider connects. If the connection breaks, the provider also tries to connect again.
You can also call setProviderAndWait(). That method waits for the provider before Quarkus finishes startup. Use it when running with a default would be unsafe. Our quote service can use stable, so it starts without waiting.
When Quarkus stops, the shutdown observer closes the provider’s gRPC connection. The test profile skips provider setup. This gives us the same state in every test, and we do not need a container.
Define the targeting rule
Create flagd/pricing-flags.json at the module root:
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"flags": {
"pricing-engine": {
"state": "ENABLED",
"variants": {
"stable": "stable",
"dynamic": "dynamic"
},
"defaultVariant": "stable",
"targeting": {
"if": [
{
"==": [
{
"var": "targetingKey"
},
"northwind"
]
},
"dynamic",
"stable"
]
}
}
}
}The if rule returns the name of a variant. flagd maps dynamic to the string dynamic and stable to stable. Northwind matches the rule. Every other targeting key uses the stable branch.
Start flagd with Podman:
podman run --rm --name flagd \
-p 8013:8013 \
-v "$(pwd)/flagd:/etc/flagd:ro" \
ghcr.io/open-feature/flagd:v0.16.0 \
start --uri file:/etc/flagd/pricing-flags.jsonThe command uses a fixed flagd image version and mounts the flag directory as read-only. flagd watches the file and loads changes while it runs.
In another terminal, start Quarkus:
./mvnw quarkus:devWait for this provider message:
Provider flagd transitioned from state NOT_READY to state READYVerify targeting and live changes
Request a quote for Northwind:
curl -s 'http://localhost:8088/quotes/northwind?subtotal=100.00'The targeting key selects dynamic pricing:
{"discount":10.00,"flagOrigin":"quarkus.openfeature","pricingEngine":"dynamic","subtotal":100.00,"tenantId":"northwind","total":90.00}Request the same quote for Contoso:
curl -s 'http://localhost:8088/quotes/contoso?subtotal=100.00'Contoso does not match the rule, so it gets stable:
{"discount":0.00,"flagOrigin":"quarkus.openfeature","pricingEngine":"stable","subtotal":100.00,"tenantId":"contoso","total":100.00}Now edit flagd/pricing-flags.json and replace northwind with contoso. Keep both processes running. flagd sees the file change. The next requests return the opposite results:
northwind -> stable -> total 100.00
contoso -> dynamic -> total 90.00The rule moved to flagd. PricingService still asks the same Flags API for a string, and its business code stays the same.
Step 7. Check the failure behavior
Stop the current Quarkus process first. Then stop flagd and start Quarkus again. We now have a clean start with flagd down:
podman stop flagd
./mvnw quarkus:devThe application starts and the request still succeeds:
curl -s 'http://localhost:8088/quotes/contoso?subtotal=100.00'{"discount":0.00,"flagOrigin":"quarkus.openfeature","pricingEngine":"stable","subtotal":100.00,"tenantId":"contoso","total":100.00}The application log also shows the provider error:
OpenFeature evaluation error for flag 'pricing-engine': GENERAL [DEADLINE_EXCEEDED: ...]If the request arrives while the provider is still starting, the code may be PROVIDER_NOT_READY instead. The request gets the default in both cases. The warning tells us that the value did not come from flagd.
In production, I would send provider errors and ready events to metrics and health checks. Logs alone are easy to miss.
I would also check the default for every flag. A flag for a visual feature can often use a local default. A flag that controls access, data deletion, or financial limits needs a stricter choice. It may need a fail-closed value, which denies the operation when the lookup fails. Or the application may need to stop at startup through setProviderAndWait().
When flagd starts later, the provider moves from ERROR to READY. Quarkus keeps running. You still need an alert because the application may have used the default while flagd was down.
Test which provider wins
quarkus-flags checks providers in this order:
in-memory flags
OpenFeature flags
config-backed flags
The in-memory provider comes first. A test can set a different value without changing the application code. Create src/test/java/com/ibm/developer/pricing/QuoteResourceTest.java:
package com.ibm.developer.pricing;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.is;
import jakarta.inject.Inject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import io.quarkiverse.flags.Flag;
import io.quarkiverse.flags.InMemoryFlagProvider;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class QuoteResourceTest {
@Inject
InMemoryFlagProvider inMemoryFlags;
@AfterEach
void removeOverride() {
inMemoryFlags.removeFlag("pricing-engine");
}
@Test
void usesSafeDefaultWhenOpenFeatureProviderIsUnavailable() {
given()
.queryParam("subtotal", "100.00")
.when().get("/quotes/contoso")
.then()
.statusCode(200)
.body("tenantId", is("contoso"))
.body("pricingEngine", is("stable"))
.body("discount", is(0.0f))
.body("total", is(100.0f))
.body("flagOrigin", is("quarkus.openfeature"));
}
@Test
void inMemoryFlagOverridesOpenFeatureWithoutChangingBusinessCode() {
inMemoryFlags.addFlag(Flag.builder("pricing-engine").setString("dynamic"));
given()
.queryParam("subtotal", "100.00")
.when().get("/quotes/northwind")
.then()
.statusCode(200)
.body("pricingEngine", is("dynamic"))
.body("discount", is(10.0f))
.body("total", is(90.0f))
.body("flagOrigin", is("quarkus.in-memory"));
}
@Test
void rejectsNonPositiveSubtotal() {
given()
.queryParam("subtotal", "0")
.when().get("/quotes/contoso")
.then()
.statusCode(400);
}
}Run the suite:
./mvnw testExpected:
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe first test checks the adapter default. The second test shows that the in-memory value wins. Production can still use OpenFeature.
Where to take the migration next
Move one key at a time. Set its OpenFeature type and choose a safe default. Then copy its targeting rule to the new flag service and add a test. Write the test against either the provider or the HTTP endpoint. Remove the old config setting after these checks pass. Other keys can stay where they are until you are ready.
The adapter supports boolean, string, integer, and double flags. quarkus-flags returns double values as BigDecimal. It does not support OpenFeature object flags. Check the types of your current flags before you start moving them.
I tested this example in JVM mode. The flagd provider is a third-party Java library. It does not document Quarkus native-image support, so test the native build and deployment separately.
Registering a key with OpenFeature moves both its value and its fallback to that provider. Check both for every flag you move. The business code can then keep using quarkus-flags, even when the flag service changes.


