Status: Accepted
Date: 2026-06-27
Deciders: Stellar-Spend core team
As Stellar-Spend adds new corridors, UI features, and backend capabilities, we need a mechanism to:
- Gradually roll out new features to a subset of users before full release
- Kill-switch a problematic feature without deploying new code
- A/B test UI changes (e.g., new quote display, alternative fee selectors)
- Gate features behind KYC tier, wallet type, or environment (staging vs production)
Currently, environment variables (NEXT_PUBLIC_*) are used as on/off toggles. This is inflexible — changing a flag requires a redeployment.
Options considered:
Option A: Environment variable flags (status quo)
NEXT_PUBLIC_ENABLE_KES_CORRIDOR=true. Simple, but requires redeploy to toggle; no gradual rollout.
Option B: Database-backed flags with admin API
Flags stored in a DB table; toggled via an admin endpoint. Runtime updates without redeploy. Requires DB query on every request unless cached.
Option C: Third-party feature flag service
LaunchDarkly, PostHog, etc. Full-featured (gradual rollout, A/B, analytics). Adds external dependency and cost.
Option D: In-process flag store with DB persistence and caching
Flags defined in a schema with defaults; overrides stored in DB; resolved at request time from a short-TTL in-process cache. No external dependency; supports gradual rollout via percentage-based resolution.
Adopt Option D: an in-process flag store with DB persistence, caching, and a typed schema.
src/lib/feature-flags/
schema.ts — Flag definitions, default values, rollout config
store.ts — FeatureFlagStore: resolves flags with cache + overrides
server.ts — Server-side helpers: isFlagEnabled(), setFlagOverrides()
index.ts — Public API
src/hooks/useFeatureFlag.ts — Client-side hook (reads server-resolved flags from API)
src/app/api/admin/feature-flags/route.ts — Admin CRUD endpoint
// src/lib/feature-flags/schema.ts
export const FLAGS = {
'corridor.kes': {
default: false,
description: 'Enable Kenya KES corridor',
},
'ui.quote-comparison': {
default: false,
rollout: 0.1, // 10% of requests
description: 'Show QuoteComparison component to a percentage of users',
},
'onramp.moonpay': {
default: false,
description: 'Enable MoonPay on-ramp provider',
},
} satisfies FlagSchema;- Request-level override (e.g.,
?flag=corridor.kes:1in development only) - DB override (admin-set value with optional expiry)
- Gradual rollout (seeded by request ID for stable per-user assignment)
- Default value from schema
Server components resolve flags directly via isFlagEnabled(name). Client components use the useFeatureFlag(name) hook which reads a /api/feature-flags endpoint that returns all enabled flags for the current session. The endpoint response is cached for 60 seconds.
Flags can be toggled via PUT /api/admin/feature-flags (requires admin API key). Overrides are persisted to DB and take effect within 60 seconds (cache TTL).
Positive:
- Flag changes take effect within 60 seconds without deployment
- Gradual rollout is deterministic per-request (stable for a given user across sessions via request ID seeding)
- Typed schema prevents typos —
isFlagEnabled('coridorkes')is a TypeScript error - No external dependency; works offline and in test environments
- Admin API allows operational toggling during incidents
Negative / Trade-offs:
- Gradual rollout is per-request, not per-user — a user may see different behavior across sessions if they don't have a persistent session ID
- No built-in analytics (who saw flag X, what was the conversion rate). PostHog or Sentry can be added for this separately
- The 60-second cache TTL means kill-switches are not instantaneous. For true emergency kill-switches, an environment variable redeploy is still faster
- DB-backed flags require a migration to add the
feature_flagstable; until then, flags default to schema defaults
Conventions:
- Flag names use dot notation:
module.feature(e.g.,corridor.ghs,ui.new-form) - Flags are additive — old flags are deprecated, not deleted, to prevent accidental re-activation
- All flag evaluations in server code go through
isFlagEnabled()fromsrc/lib/feature-flags/server.ts - Never use
process.envdirectly for feature gating; use the flag store
Related: [[ADR-005-environment-variable-validation]], [[ADR-009-provider-abstraction-routing]]