Backend-driven feature flag service built with Spring Boot 4 and Java 25.
Exposes a single endpoint that evaluates a set of feature flags for the authenticated customer and returns which features are available to them. Flag configuration lives in application.yaml and is overridden at runtime by Azure Key Vault secrets.
feature-flags-api.yaml (OpenAPI spec)
│
▼ ./gradlew openApiGenerate
FeatureFlagsApi.java ←── implemented by ──→ FeatureFlagController.java
FeatureFlagsResponse.java ◄── assembled by ── FeatureFlagEvaluationService.java
FeatureFlag.java
FeatureName.java (enum — CUSTOMER_DIALOGUE_CHAT, VOICE_SUPPORT_TOKEN, ONBOARDING_FLOW)
▲
│ reads
FeatureFlagProperties.java ←── bound from ── application.yaml / Key Vault
FeatureFlagEvaluationService fails fast at startup if application.yaml doesn't have a config entry for every value in the generated FeatureName enum — the response always contains exactly those three flags, never more or fewer.
The OpenAPI spec is the contract source of truth. DTOs and the controller interface are generated from it — never edit build/generated/ by hand. When the contract changes, update the spec and re-run ./gradlew openApiGenerate; the compiler will flag any breaking changes in the controller or service immediately.
- Java 25
- Gradle (wrapper included — no local install needed)
./gradlew bootRunThe server starts on port 8080.
Override any flag at startup via environment variables:
FEATURE_FLAG_CUSTOMER_DIALOGUE_CHAT_ENABLED=true ./gradlew bootRunReturns the evaluated feature set for the calling customer. Takes no parameters — it's not
part of the OpenAPI contract, but internally the controller still reads the following
request headers to build the customer context (MVP stub — will come from auth token /
SecurityContext in production):
| Header | Description |
|---|---|
X-Customer-SSN |
Customer SSN for allowlist targeting. |
X-App-Version |
Client app version in semver format (e.g. 2.1.0). |
X-Platform |
Client platform: ios, android, or web. Reserved for platform check (next version). |
Response 200 OK
{
"features": [
{ "name": "CUSTOMER_DIALOGUE_CHAT", "enabled": false },
{ "name": "VOICE_SUPPORT_TOKEN", "enabled": true },
{ "name": "ONBOARDING_FLOW", "enabled": true }
]
}Response 400 Bad Request — malformed X-App-Version header.
Example
curl http://localhost:8080/api/v1/feature-flags \
-H "X-Customer-SSN: 199001011234" \
-H "X-App-Version: 2.0.0" \
-H "X-Platform: ios"Flags are evaluated in this order for each request:
- Global switch — if
enabled: false, the flag is off for everyone. - Version check — if the client's
X-App-Versionis belowmin-app-version, the flag is off. - Allowlist check — if
use-allowlist: true, the flag is on only if the customer's SSN appears inallowlist. Ifuse-allowlist: false, the flag is on for all users who passed the previous checks.
Flag configuration lives in src/main/resources/application.yaml. Each scalar field accepts an environment variable with a local default:
feature-flags:
flags:
customer-dialogue-chat:
enabled: ${FEATURE_FLAG_CUSTOMER_DIALOGUE_CHAT_ENABLED:false}
min-app-version: ${FEATURE_FLAG_CUSTOMER_DIALOGUE_CHAT_MIN_VERSION:1.0.0}
use-allowlist: ${FEATURE_FLAG_CUSTOMER_DIALOGUE_CHAT_USE_ALLOWLIST:true}
allowlist:
- "199001011234"
voice-support-token:
enabled: ${FEATURE_FLAG_VOICE_SUPPORT_TOKEN_ENABLED:true}
min-app-version: ${FEATURE_FLAG_VOICE_SUPPORT_TOKEN_MIN_VERSION:1.0.0}
use-allowlist: ${FEATURE_FLAG_VOICE_SUPPORT_TOKEN_USE_ALLOWLIST:false}
allowlist: []
onboarding-flow:
enabled: ${FEATURE_FLAG_ONBOARDING_FLOW_ENABLED:true}
min-app-version: ${FEATURE_FLAG_ONBOARDING_FLOW_MIN_VERSION:1.0.0}
use-allowlist: ${FEATURE_FLAG_ONBOARDING_FLOW_USE_ALLOWLIST:false}
allowlist: []The flag set is a fixed enum shared with the OpenAPI contract, so the frontend can validate
features[].name against a known list rather than an open-ended set of keys. features is
an array of { name, enabled } — name is $ref'd straight to the FeatureName schema,
so adding a flag doesn't require touching the response schema itself. Adding one requires:
- Add the new name to the
FeatureNameenum inopenapi/feature-flags-api.yaml. - Add a matching entry under
feature-flags.flagsinapplication.yaml(kebab-case of the enum name).FeatureFlagEvaluationServicefails fast at startup if the two are out of sync. - Run
./gradlew openApiGenerate.FeatureFlagEvaluationService.evaluatealready loops overFeatureName.values(), so no code change is needed there.
Flag values are injected by Azure Key Vault via Spring Cloud Azure's property source, which overrides application.yaml values at startup. The ${ENV_VAR:default} placeholders in application.yaml serve as local fallbacks when Key Vault is not configured.
Azure Key Vault secret names use double-dash (--) as a property path separator (Key Vault disallows dots and underscores):
| Key Vault secret name | Spring property path |
|---|---|
feature-flags--flags--voice-support-token--enabled |
feature-flags.flags.voice-support-token.enabled |
feature-flags--flags--voice-support-token--min-app-version |
feature-flags.flags.voice-support-token.min-app-version |
feature-flags--flags--voice-support-token--use-allowlist |
feature-flags.flags.voice-support-token.use-allowlist |
feature-flags--flags--voice-support-token--allowlist[0] |
feature-flags.flags.voice-support-token.allowlist[0] |
Uncomment the Spring Cloud Azure block in build.gradle and set AZURE_KEYVAULT_URI:
export AZURE_KEYVAULT_URI=https://my-vault.vault.azure.net/
./gradlew bootRunAuthentication is handled by DefaultAzureCredential — managed identity in Azure, az login locally.
Note: Spring Cloud Azure 5.x targets Spring Boot 3.x / Spring Framework 6.x. The dependency block in
build.gradleis currently commented out pending Spring Boot 4 support. Until then, inject secrets as environment variables via Azure App Service Key Vault References or Kubernetes External Secrets Operator — the${ENV_VAR:default}placeholders support this with no code changes.
./gradlew testUnit tests cover all evaluation branches in FeatureFlagEvaluationServiceTest:
| Test | Scenario |
|---|---|
globally_disabled_flag_is_always_off |
enabled: false overrides everything |
enabled_flag_with_no_restrictions_is_on |
open flag returns true for any user |
allowlisted_flag_is_on_for_allowed_user |
SSN in allowlist → enabled |
allowlisted_flag_is_off_for_non_allowed_user |
SSN not in allowlist → disabled |
allowlisted_flag_is_off_when_ssn_is_null |
null SSN treated as not allowlisted |
version_gated_flag_is_off_when_client_version_too_low |
version below min → disabled |
version_gated_flag_is_on_when_client_version_meets_minimum |
exact min version → enabled |
version_gated_flag_is_on_when_client_version_exceeds_minimum |
version above min → enabled |
missing_app_version_bypasses_version_check |
null version skips check → enabled |
response_always_contains_all_three_flags |
response always has all three named flags |
missing_flag_configuration_throws_illegal_state |
config missing an enum flag → fails fast at startup |
malformed_version_throws_illegal_argument |
invalid version string → 400 |
src/main/
├── java/com/poc/featureflags/
│ ├── FeatureFlagsApplication.java
│ ├── config/
│ │ ├── FeatureFlagConfig.java # per-flag settings record
│ │ └── FeatureFlagProperties.java # @ConfigurationProperties root
│ ├── context/
│ │ └── CustomerContext.java # customer identity stub
│ ├── controller/
│ │ └── FeatureFlagController.java # implements generated FeatureFlagsApi
│ ├── exception/
│ │ └── GlobalExceptionHandler.java
│ └── service/
│ └── FeatureFlagEvaluationService.java
└── resources/
├── application.yaml
└── openapi/
└── feature-flags-api.yaml # OpenAPI spec (source of truth)
build/generated/openapi/ # generated — never edit, not committed
└── com/poc/featureflags/
├── api/FeatureFlagsApi.java
└── dto/
├── FeatureFlagsResponse.java
├── FeatureFlag.java
└── FeatureName.java