Feature Flags Architecture: Progressive Delivery for Safe Production Deployments

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     │
└──────────────────┴──────────────┴───────────────────────────┘
Feature flags architecture diagram
Feature flag types serve different purposes with different lifecycle expectations

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);
    }
  }
}
Progressive delivery monitoring dashboard
Monitoring feature rollout metrics and error rates during progressive delivery

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;
  }
}
Feature flag management dashboard
Tracking flag lifecycle and identifying stale flags for cleanup

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

External Resources

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top