Feature Flags Architecture for Progressive Delivery
Feature flags architecture enables teams to decouple deployment from release. You deploy code to production continuously, but control which users see new features through configuration rather than code branches. This pattern — called progressive delivery — lets you test features with small user groups, roll back instantly without redeploying, and run A/B experiments safely.
This guide covers the architectural patterns for building a robust feature flag system, from simple boolean toggles to sophisticated targeting rules with percentage rollouts. You will learn the common pitfalls, including the technical debt trap that catches most teams.
Types of Feature Flags
Not all feature flags serve the same purpose. Understanding the types helps you set appropriate lifetimes and ownership:
Flag Types and Lifetimes:
┌──────────────────┬──────────────┬───────────────────────────┐
│ Type │ Lifetime │ Purpose │
├──────────────────┼──────────────┼───────────────────────────┤
│ Release Toggle │ Days-Weeks │ Progressive feature rollout│
│ Experiment │ Weeks-Months │ A/B testing, metrics │
│ Ops Toggle │ Permanent │ Kill switches, circuit │
│ │ │ breakers │
│ Permission │ Permanent │ Entitlement, plan-based │
│ │ │ access │
│ Dev Toggle │ Hours-Days │ WIP features in trunk │
└──────────────────┴──────────────┴───────────────────────────┘
Core Architecture
// Feature flag service architecture
interface FeatureFlag {
key: string;
type: "release" | "experiment" | "ops" | "permission";
enabled: boolean;
rules: TargetingRule[];
defaultValue: boolean | string | number;
metadata: {
owner: string;
createdAt: string;
expiresAt?: string; // Force cleanup for release flags
description: string;
jiraTicket?: string;
};
}
interface TargetingRule {
priority: number;
conditions: Condition[]; // AND logic within a rule
percentage?: number; // Percentage rollout
value: boolean | string | number;
}
interface Condition {
attribute: string; // user.plan, user.country, etc.
operator: "eq" | "neq" | "in" | "contains" | "gte" | "lte";
values: string[];
}
// Example: Progressive rollout with targeting
const newCheckoutFlag: FeatureFlag = {
key: "new-checkout-flow",
type: "release",
enabled: true,
defaultValue: false,
rules: [
// Rule 1: Always on for internal team
{
priority: 1,
conditions: [{ attribute: "user.email", operator: "contains", values: ["@mycompany.com"] }],
value: true,
},
// Rule 2: 25% rollout for US users
{
priority: 2,
conditions: [{ attribute: "user.country", operator: "eq", values: ["US"] }],
percentage: 25,
value: true,
},
// Rule 3: Beta users always get it
{
priority: 3,
conditions: [{ attribute: "user.plan", operator: "eq", values: ["beta"] }],
value: true,
},
],
metadata: {
owner: "checkout-team",
createdAt: "2026-03-01",
expiresAt: "2026-04-15",
description: "New checkout flow with one-click purchase",
jiraTicket: "CHECKOUT-1234",
},
};
Evaluation Engine
class FlagEvaluator {
evaluate(flag: FeatureFlag, context: UserContext): FlagResult {
if (!flag.enabled) {
return { value: flag.defaultValue, reason: "flag_disabled" };
}
// Evaluate rules in priority order
const sortedRules = [...flag.rules].sort((a, b) => a.priority - b.priority);
for (const rule of sortedRules) {
if (this.matchesConditions(rule.conditions, context)) {
// Handle percentage rollout
if (rule.percentage !== undefined) {
const hash = this.consistentHash(flag.key, context.userId);
if (hash <= rule.percentage) {
return { value: rule.value, reason: "rule_match_percentage" };
}
continue; // Percentage didn't match, try next rule
}
return { value: rule.value, reason: "rule_match" };
}
}
return { value: flag.defaultValue, reason: "default" };
}
// Consistent hashing ensures same user always gets same result
private consistentHash(flagKey: string, userId: string): number {
const input = flagKey + userId;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 100;
}
}
Kill Switches for Production Safety
// Ops toggle pattern — instant feature disable
class PaymentService {
async processPayment(order: Order): Promise {
// Kill switch: instantly disable new payment provider
if (!flags.isEnabled("payment-provider-stripe-v2", order.user)) {
return this.legacyPaymentFlow(order);
}
try {
return await this.stripeV2Payment(order);
} catch (error) {
// Auto-disable on error threshold
metrics.increment("payment.stripe_v2.error");
if (metrics.errorRate("payment.stripe_v2") > 0.05) {
await flags.disable("payment-provider-stripe-v2");
alerting.critical("Stripe V2 auto-disabled: error rate > 5%");
}
return this.legacyPaymentFlow(order);
}
}
}
Managing Feature Flag Debt
The #1 problem with feature flags architecture is technical debt. Teams add flags but never remove them. After a year, you have hundreds of stale flags creating dead code paths and making the codebase harder to understand.
// Automated flag cleanup pipeline
class FlagCleanupService {
async findStaleTags(): Promise {
const flags = await this.flagStore.getAll();
const stale: StaleFlag[] = [];
for (const flag of flags) {
// Release flags older than expiry date
if (flag.type === "release" && flag.metadata.expiresAt) {
if (new Date(flag.metadata.expiresAt) < new Date()) {
stale.push({ flag, reason: "expired" });
}
}
// Flags enabled for 100% for over 30 days
if (this.isFullyRolledOut(flag) && this.daysActive(flag) > 30) {
stale.push({ flag, reason: "fully_rolled_out" });
}
// Flags with no evaluations in 14 days
const lastEval = await this.metrics.lastEvaluation(flag.key);
if (!lastEval || daysSince(lastEval) > 14) {
stale.push({ flag, reason: "unused" });
}
}
return stale;
}
}
When NOT to Use Feature Flags
Feature flags add complexity to your codebase and testing matrix. Therefore, skip them for: database schema changes (use migrations), one-time data backfills, changes that must be atomic across all users, or features so small they can be reviewed and rolled back via git revert. As a result, reserve flags for user-facing features with meaningful rollout risk.
Key Takeaways
Feature flags enable progressive delivery — the safest way to ship features to production. Categorize flags by type, set expiration dates on release flags, and automate cleanup. The architecture pays for itself with the first production incident you avoid through instant rollback.
Related Reading
- Platform Engineering & Developer Portal
- Modular Monolith vs Microservices
- GitOps with ArgoCD & Crossplane