Stop Redeploying for Toggles: Feature Flags for Serious Java Systems
Build identity-aware, database-backed feature flags in Quarkus with REST APIs, Qute views, and instant runtime control.
There is a moment every Java team knows. A feature is “done,” the code is merged, and someone asks the most dangerous question in distributed systems: can we ship it, but not really turn it on yet?
This tutorial is about building the system that lets you answer “yes” without lying to yourself.
We will build a real feature flag system using Quarkus, not a boolean in application.properties. Flags will live in a database, evaluate at runtime, drive REST behavior, and surface in a Qute UI so you can see exactly what production thinks is enabled. It is early days for the extension so the team is always looking for feedback. Feel free to contribute to the Github project with issues and ideas!
This is not a conceptual guide. We will end with a running system you can keep.
The Failure That Forces the Architecture
The first time I saw feature flags done wrong, they were implemented as environment variables baked into container images. Toggling a feature meant rebuilding, redeploying, and praying the rollout didn’t overlap with traffic spikes. When something went wrong, rollback took longer than the outage itself.
The root problem wasn’t tooling. It was coupling release mechanics to runtime behavior. In a distributed Java system, once a service is live, decisions must be data-driven, not deployment-driven. Feature flags are the smallest unit of that separation.
That’s the system we’re going to build.
Project Setup: Start With the Right Spine
Prerequisites: Java 17+, Maven, and a working container runtime. That’s it.
We start with a clean Quarkus application using REST, Qute for server-side HTML, and Hibernate ORM backed by PostgreSQL via Dev Services.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=feature-flags \
-Dextensions="rest-jackson,quarkus-rest,rest-qute,hibernate-orm-panache,jdbc-postgresql"
cd feature-flagsThe important choice here is quarkus-rest-jackson, not legacy stacks. Everything we build depends on fast startup, low overhead, and predictable request handling.
If you do not want to follow along, feel free to grab to code from my Github repository!
Dependencies: Flags Are a First-Class Subsystem
We add the Quarkiverse feature flags extensions. These give us a runtime evaluation engine instead of reinventing one.
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags</artifactId>
<version>1.0.0.Beta5</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags-hibernate-orm</artifactId>
<version>1.0.0.Beta5</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags-security</artifactId>
<version>1.0.0.Beta5</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags-qute</artifactId>
<version>1.0.0.Beta5</version>
</dependency>At this point we have a flag engine, a database-backed provider, security, and template integration. Nothing is wired yet, but the spine is there.
Configuration: Let Dev Services Do the Heavy Lifting
There is no Docker command in this tutorial. PostgreSQL will appear automatically.
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.flags.runtime."new-ui".value=false
quarkus.flags.runtime."api-v2".value=trueConfig-based flags exist for one reason only: bootstrapping. Everything that matters in production will move out of config and into data.
Persistence: Flags Are Data, Treat Them Like Data
A feature flag that can’t be audited is a liability. We model flags as entities.
package com.example.flags;
import io.quarkiverse.flags.hibernate.common.FlagDefinition;
import io.quarkiverse.flags.hibernate.common.FlagFeature;
import io.quarkiverse.flags.hibernate.common.FlagValue;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@FlagDefinition
@Entity
public class FeatureFlag extends PanacheEntity {
@FlagFeature
public String feature;
@FlagValue
public String value;
public String description;
}This is not an implementation detail. This is the contract between your runtime and your operations team.
Create Your Domain Model
Just a small one. Don’t need much today.
package com.example.entity;
public class Product {
public Long id;
public String name;
public String category;
public Double price;
public String sku;
public Product() {}
public Product(Long id, String name, String category, Double price, String sku) {
this.id = id;
this.name = name;
this.category = category;
this.price = price;
this.sku = sku;
}
}Bootstrapping Reality at Startup
For this demo we seed flags via an initializer so the system has shape on day one. You would normally do this via Flyway or other means of database initalization. It also helps to get a feel for what metadata could be relevant.
package com.example.flags;
import java.util.HashMap;
import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class FeatureFlagInitializer {
@Startup
@Transactional
void init() {
FeatureFlag.deleteAll();
var premium = new FeatureFlag();
premium.feature = "premium-features";
premium.value = "true";
premium.metadata = new HashMap<>();
premium.metadata.put("owner", "platform-team");
premium.metadata.put("status", "active");
premium.metadata.put("category", "feature");
premium.metadata.put("tags", "api,premium,billing");
premium.metadata.put("riskLevel", "medium");
premium.metadata.put("documentationUrl", "https://wiki.example.com/premium-features");
premium.description = "Expose premium API fields";
premium.persist();
var bulk = new FeatureFlag();
bulk.feature = "bulk-operations";
bulk.value = "false";
bulk.metadata = new HashMap<>();
bulk.description = "Enable bulk updates";
bulk.persist();
}
}From here on, toggling a feature is a database write, not a redeploy.
Business Logic: Flags Decide Behavior, Not Routing
We build a small product service. The important part is where flags are checked.
package com.example.service;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import com.example.entity.Product;
import io.quarkiverse.flags.Flags;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class ProductService {
@Inject
Flags flags;
private final List<Product> products = Arrays.asList(
new Product(1L, "Laptop", "Electronics", 999.99, "LAP-001"),
new Product(2L, "Mouse", "Electronics", 29.99, "MOU-001"),
new Product(3L, "Desk", "Furniture", 299.99, "DSK-001"),
new Product(4L, "Chair", "Furniture", 199.99, "CHR-001"));
public List<Product> getAllProducts() {
return products;
}
public List<Product> getProductsWithDetails() {
// Premium feature: includes SKU information
if (flags.isEnabled("premium-features")) {
return products;
}
// Basic response without SKU
return products.stream()
.map(p -> new Product(p.id, p.name, p.category, p.price, null))
.collect(Collectors.toList());
}
public boolean canPerformBulkOperations() {
return flags.isEnabled("bulk-operations");
}
public List<Product> bulkUpdatePrices(Double multiplier) {
if (!canPerformBulkOperations()) {
throw new IllegalStateException("Bulk operations feature is disabled");
}
return products.stream()
.peek(p -> p.price = p.price * multiplier)
.collect(Collectors.toList());
}
}Notice what we didn’t do. We didn’t hide endpoints. We didn’t fork controllers. We let data shape change, not API shape. This is how you avoid client breakage.
REST Layer: Thin, Boring, Honest
package com.example;
import java.util.List;
import java.util.Map;
import com.example.entity.Product;
import com.example.flags.FeatureFlag;
import com.example.service.ProductService;
import io.quarkiverse.flags.Flags;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@Inject
ProductService productService;
@Inject
Flags flags;
@GET
public List<Product> getProducts() {
return productService.getProductsWithDetails();
}
@GET
@Path("/bulk-update")
public Response bulkUpdatePrices(@QueryParam("multiplier") Double multiplier) {
if (multiplier == null || multiplier <= 0) {
return Response.status(400)
.entity(Map.of("error", "Invalid multiplier"))
.build();
}
try {
List<Product> updated = productService.bulkUpdatePrices(multiplier);
return Response.ok(updated).build();
} catch (IllegalStateException e) {
return Response.status(403)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
@GET
@Path("/features")
public Map<String, Boolean> getFeatureStatus() {
return Map.of(
"premium-features", safeIsEnabled("premium-features"),
"bulk-operations", safeIsEnabled("bulk-operations"),
"analytics-dashboard", safeIsEnabled("analytics-dashboard"),
"new-ui", safeIsEnabled("new-ui"),
"spring-sale", safeIsEnabled("spring-sale"));
}
@PUT
@Path("/features/bulk-operations/toggle")
@Transactional
public Response toggleBulkOperations() {
FeatureFlag flag = FeatureFlag.find("feature", "bulk-operations").firstResult();
if (flag == null) {
return Response.status(404)
.entity(Map.of("error", "bulk-operations flag not found"))
.build();
}
// Toggle the value
boolean currentValue = "true".equalsIgnoreCase(flag.value);
flag.value = currentValue ? "false" : "true";
flag.persist();
return Response.ok(Map.of(
"flag", "bulk-operations",
"enabled", !currentValue,
"message", "Flag toggled successfully"))
.build();
}
private boolean safeIsEnabled(String flagName) {
try {
return flags.isEnabled(flagName);
} catch (Exception e) {
return false;
}
}
}The REST layer doesn’t know why something is enabled. It just serves reality. And helps you switch an example toggle.
Qute UI: Make Flags Visible
If flags are invisible, they will be abused.
Create src/main/resources/templates/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Feature Flags Dashboard</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #4695EB;
border-bottom: 3px solid #4695EB;
padding-bottom: 10px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.feature-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.feature-card h3 {
margin-top: 0;
color: #333;
}
.status {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-weight: bold;
font-size: 14px;
}
.status.enabled {
background: #4CAF50;
color: white;
}
.status.disabled {
background: #f44336;
color: white;
}
.api-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-top: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<h1>🚩 Quarkus Feature Flags Dashboard</h1>
<div class="feature-grid">
<div class="feature-card">
<h3>Premium Features</h3>
{#if flag:enabled('premium-features')}
<span class="status enabled">✓ ENABLED</span>
<p>Premium API endpoints are accessible with detailed product information.</p>
{#else}
<span class="status disabled">✗ DISABLED</span>
<p>Premium features are currently unavailable.</p>
{/if}
</div>
<div class="feature-card">
<h3>Bulk Operations</h3>
{#if flag:enabled('bulk-operations')}
<span class="status enabled">✓ ENABLED</span>
<p>Users can perform bulk price updates and batch operations.</p>
{#else}
<span class="status disabled">✗ DISABLED</span>
<p>Bulk operations are disabled for maintenance.</p>
{/if}
</div>
<div class="feature-card">
<h3>Analytics Dashboard</h3>
{#if flag:enabled('analytics-dashboard')}
<span class="status enabled">✓ ENABLED</span>
<p>New analytics dashboard with advanced metrics is live.</p>
{#else}
<span class="status disabled">✗ DISABLED</span>
<p>Analytics dashboard is coming soon.</p>
{/if}
</div>
<div class="feature-card">
<h3>New UI</h3>
{#if flag:enabled('new-ui')}
<span class="status enabled">✓ ENABLED</span>
<p>You're experiencing the new user interface.</p>
{#else}
<span class="status disabled">✗ DISABLED</span>
<p>Using classic UI. New design coming soon!</p>
{/if}
</div>
<div class="feature-card">
<h3>Spring Sale</h3>
{#if flag:enabled('spring-sale')}
<span class="status enabled">✓ ACTIVE</span>
<p>Spring sale promotions are currently active!</p>
{#else}
<span class="status disabled">✗ INACTIVE</span>
<p>Spring sale starts March 1st, 2026.</p>
{/if}
</div>
</div>
<div class="api-section">
<h2>Available API Endpoints</h2>
<ul>
<li><code>GET /api/products</code> - Get all products (premium details if flag enabled)</li>
<li><code>GET /api/products/bulk-update?multiplier=1.1</code> - Bulk update prices (if flag enabled)</li>
<li><code>GET /api/products/features</code> - Get current feature flag status</li>
</ul>
</div>
</body>
</html>package com.example.web;
import io.quarkus.qute.Template;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/")
public class WebResource {
@Inject
Template index;
@GET
public Object page() {
return index.instance();
}
}
Production Hardening: Why This Survives Real Traffic
This system survives because flag evaluation is local, synchronous, and deterministic. No network calls. No remote control planes. The database becomes a consistency anchor, not a bottleneck, because flags are read far more than written.
We consciously trade global rollout precision for operational simplicity. Percentage rollouts, per-user bucketing, and rule chains can be added later, but only after the team understands the cost. The dangerous flags are the ones that look clever.
Most importantly, we removed redeploys from the critical path of decision-making. That alone eliminates an entire class of outages.
Verification: Watch It Change Live
Run the app.
./mvnw quarkus:devHit the API.
curl http://localhost:8080/api/productsLook at all available features:
curl http://localhost:8080/api/products/featuresWhich prints:
{
"spring-sale": false,
"bulk-operations": false,
"new-ui": false,
"analytics-dashboard": false,
"premium-features": true
}Now flip a flag:
curl -X PUT http://localhost:8080/api/products/features/bulk-operations/toggleRefresh. The response shape changes immediately. No restart. No redeploy. No drama. You can also see this in the UI:
Security-Aware Feature Flags: Let Identity Decide
The moment feature flags start controlling who sees a capability, not just whether it exists, they cross from deployment tooling into security architecture.
I have not added this feature to this tutorial yet, but wanted to share what’s possible so you can extend and play around with it.
In Quarkus, this boundary is explicit. Feature flag evaluation can be wired directly into the active security context, so flags stop being global switches and start behaving like policy decisions.
To enable this, the application needs the security-aware flag evaluator. This is a deliberate opt-in because once flags depend on identity, they are no longer static runtime values.
<dependency>
<groupId>io.quarkiverse.flags</groupId>
<artifactId>quarkus-flags-security</artifactId>
</dependency>With this extension in place, feature evaluation gains access to the current SecurityIdentity. That identity is the same one Quarkus uses everywhere else: populated by authentication, enriched with roles, and scoped to the active request. There is no parallel security model and no duplication of logic.
A security-aware flag configuration looks deceptively simple.
quarkus.flags.runtime.delta.value=true
quarkus.flags.runtime.delta.meta.evaluator=quarkus.security.identity
quarkus.flags.runtime.delta.meta.authenticated=true
quarkus.flags.runtime.delta.meta.roles-allowed=foo,barWhat happens at runtime is more interesting. The flag named delta defaults to enabled, but its final value is computed on every request. The evaluator checks whether a SecurityIdentity exists, whether the request is authenticated, and whether the user holds at least one of the allowed roles. If any of those conditions fail, the flag resolves to false even though it is “on” in configuration.
This distinction matters. The flag is not protecting an endpoint. It is shaping behavior inside the application based on identity. That allows you to keep APIs stable while progressively exposing functionality to different user groups, which is far safer than splitting endpoints or duplicating resources.
Security-based flags are especially effective when combined with shared services. A single service method can serve admins, beta users, and regular users by branching on flag evaluation rather than role checks scattered throughout the codebase. The security rules stay declarative, centralized, and auditable.
Gradual Rollout Per User, Not Per Deployment
The security extension also ships with a second evaluator that solves a different problem: controlled rollout without infrastructure gymnastics. The UsernameRolloutFlagEvaluator enables percentage-based activation tied to user identity.
quarkus.flags.runtime.delta.value=true
quarkus.flags.runtime.delta.meta.evaluator=quarkus.security.username-rollout
quarkus.flags.runtime.delta.meta.rollout-percentage=20Here, the flag is enabled for a fixed percentage of users, determined by a consistent numerical representation of their username. The important detail is consistency. A given user will always see the same result across requests and restarts, which avoids the chaos of random sampling while still allowing gradual exposure.
From a system perspective, this evaluator is cheap. It does not require shared state, counters, or coordination between nodes. Every instance arrives at the same decision independently, which is exactly what you want in a horizontally scaled service.
You can ship a feature, enable it for a small slice of real users, observe behavior under production load, and increase the percentage as confidence grows. If something goes wrong, reducing the rollout percentage is a configuration change, not an incident response.
The Real Lesson
Feature flags are way more than booleans. They are runtime governance. In Java systems, especially distributed ones, this is how you ship without losing control.
Build them as data. Expose them honestly. And never let deployment be the only way to change behavior.
That’s how you stop shipping in the dark.



