Construire un Système de Feature Flags en 50 Lignes avec Rails.app.creds

Vous n’avez pas besoin de LaunchDarkly. Vous n’avez pas besoin de Flipper. Pour la plupart des applications Rails, vous avez besoin de feature flags versionnés, faciles à surcharger en développement, et qui ne nécessitent pas un autre service à gérer.

Rails.app.creds de Rails 8.2 vous donne exactement cela. Voici un système complet de feature flags en moins de 50 lignes de code.

L’Idée Centrale

Rails.app.creds vérifie d’abord ENV, puis les credentials chiffrées. Cela signifie :

  1. Stockez vos flags dans credentials.yml.enc (versionné, revu en PRs)
  2. Surchargez avec des variables ENV à tout moment (test, debug, déploiements progressifs)

Pas de base de données. Pas de service externe. Pas d’appels API.

Étape 1 : Stocker les Flags dans les Credentials

bin/rails credentials:edit
# config/credentials.yml.enc
features:
  new_checkout: false
  dark_mode: false
  ai_summaries: false
  beta_dashboard: false

Ce sont vos valeurs par défaut. Elles sont chiffrées, versionnées, et nécessitent une PR pour changer.

Étape 2 : Créer le Module Feature Flags

# app/models/concerns/feature_flags.rb
module FeatureFlags
  extend self

  def enabled?(flag_name)
    value = Rails.app.creds.option(:features, flag_name, default: false)
    ActiveModel::Type::Boolean.new.cast(value)
  end

  def disabled?(flag_name)
    !enabled?(flag_name)
  end

  def enable!(flag_name)
    Current.feature_overrides ||= {}
    Current.feature_overrides[flag_name] = true
  end

  def disable!(flag_name)
    Current.feature_overrides ||= {}
    Current.feature_overrides[flag_name] = false
  end

  def with(flag_name, value)
    old_value = Current.feature_overrides&.dig(flag_name)
    enable!(flag_name) if value
    disable!(flag_name) unless value
    yield
  ensure
    if old_value.nil?
      Current.feature_overrides&.delete(flag_name)
    else
      Current.feature_overrides[flag_name] = old_value
    end
  end
end

Étape 3 : Ajouter les Surcharges par Requête

Pour le contrôle des flags par requête (test, surcharges admin), connectez Current :

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :feature_overrides
end

# lib/feature_override_configuration.rb
class FeatureOverrideConfiguration
  def require(*keys)
    option(*keys)
  end

  def option(*keys, default: nil)
    return default unless keys.first == :features && keys.length == 2
    Current.feature_overrides&.dig(keys.last)
  end

  def keys = []
  def reload = nil
end

Mettez à jour la chaîne de credentials :

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  FeatureOverrideConfiguration.new,  # Portée requête en premier
  Rails.app.envs,                     # Puis ENV
  Rails.app.credentials               # Puis fichier chiffré
)

Étape 4 : Utilisez-le Partout

Dans les contrôleurs

class CheckoutController < ApplicationController
  def show
    if FeatureFlags.enabled?(:new_checkout)
      render :new_checkout
    else
      render :legacy_checkout
    end
  end
end

Dans les vues

<% if FeatureFlags.enabled?(:dark_mode) %>
  <body class="dark">
<% else %>
  <body>
<% end %>

Dans les modèles

class Order < ApplicationRecord
  def calculate_shipping
    if FeatureFlags.enabled?(:new_shipping_calculator)
      NewShippingCalculator.calculate(self)
    else
      legacy_shipping_calculation
    end
  end
end

Dans les tests

class CheckoutTest < ActionDispatch::IntegrationTest
  test "nouveau flux de checkout" do
    FeatureFlags.with(:new_checkout, true) do
      get checkout_path
      assert_select ".new-checkout-form"
    end
  end

  test "flux de checkout legacy" do
    FeatureFlags.with(:new_checkout, false) do
      get checkout_path
      assert_select ".legacy-checkout-form"
    end
  end
end

Surcharger les Flags

En développement

Surchargez n’importe quel flag sans éditer les credentials :

# Activer un flag
FEATURES__NEW_CHECKOUT=true bin/rails server

# Activer plusieurs
FEATURES__NEW_CHECKOUT=true FEATURES__DARK_MODE=true bin/rails server

En staging/production

Définissez les variables ENV dans votre config de déploiement :

# fly.toml, render.yaml, etc.
[env]
  FEATURES__BETA_DASHBOARD = "true"

Par requête (outils admin)

class Admin::FeatureFlagsController < AdminController
  def toggle
    flag = params[:flag].to_sym

    if FeatureFlags.enabled?(flag)
      FeatureFlags.disable!(flag)
    else
      FeatureFlags.enable!(flag)
    end

    redirect_back fallback_location: admin_root_path
  end
end

Avancé : Déploiements par Pourcentage

Vous voulez activer une fonctionnalité pour 10% des utilisateurs ?

# app/models/concerns/feature_flags.rb
module FeatureFlags
  def enabled_for?(flag_name, user)
    # Vérifier si explicitement activé/désactivé d'abord
    return enabled?(flag_name) if explicitly_set?(flag_name)

    # Vérifier le déploiement par pourcentage
    percentage = Rails.app.creds.option(:features, :"#{flag_name}_percentage", default: nil)
    return enabled?(flag_name) unless percentage

    # Bucketing cohérent basé sur l'ID utilisateur
    bucket = Digest::MD5.hexdigest("#{flag_name}-#{user.id}").first(8).to_i(16) % 100
    bucket < percentage.to_i
  end

  private

  def explicitly_set?(flag_name)
    Current.feature_overrides&.key?(flag_name) ||
      ENV["FEATURES__#{flag_name.to_s.upcase}"].present?
  end
end

Configurez le déploiement :

# credentials.yml.enc
features:
  new_checkout: false
  new_checkout_percentage: 10  # 10% des utilisateurs

Utilisation :

if FeatureFlags.enabled_for?(:new_checkout, current_user)
  # 10% des utilisateurs voient ceci
end

Le même utilisateur obtient toujours le même résultat (bucketing cohérent via hash MD5).

Avancé : Ciblage par Segment Utilisateur

Activez des fonctionnalités pour des segments d’utilisateurs spécifiques :

module FeatureFlags
  def enabled_for?(flag_name, user)
    return true if user_in_allowlist?(flag_name, user)
    return true if segment_enabled?(flag_name, user)
    return enabled_by_percentage?(flag_name, user)
  end

  private

  def user_in_allowlist?(flag_name, user)
    allowlist = Rails.app.creds.option(:features, :"#{flag_name}_users", default: [])
    allowlist.include?(user.id) || allowlist.include?(user.email)
  end

  def segment_enabled?(flag_name, user)
    segments = Rails.app.creds.option(:features, :"#{flag_name}_segments", default: [])
    segments.any? { |segment| user_in_segment?(user, segment) }
  end

  def user_in_segment?(user, segment)
    case segment.to_sym
    when :staff then user.staff?
    when :beta then user.beta_tester?
    when :premium then user.premium?
    else false
    end
  end
end
# credentials.yml.enc
features:
  new_checkout: false
  new_checkout_users:
    - 123  # User ID
    - "[email protected]"  # Ou email
  new_checkout_segments:
    - staff
    - beta
  new_checkout_percentage: 5  # Plus 5% de tous les autres

Stratégie de déploiement : staff d’abord, puis beta testeurs, puis 5% de tout le monde, puis augmenter le pourcentage à mesure que la confiance grandit.

L’Implémentation Complète

Voici tout en un seul endroit :

# app/models/concerns/feature_flags.rb (46 lignes)
module FeatureFlags
  extend self

  def enabled?(flag_name)
    cast_boolean(Rails.app.creds.option(:features, flag_name, default: false))
  end

  def disabled?(flag_name) = !enabled?(flag_name)

  def enabled_for?(flag_name, user)
    return true if allowlisted?(flag_name, user)
    return true if segment_match?(flag_name, user)
    return percentage_match?(flag_name, user) if rollout_percentage(flag_name)
    enabled?(flag_name)
  end

  def enable!(flag_name)
    (Current.feature_overrides ||= {})[flag_name] = true
  end

  def disable!(flag_name)
    (Current.feature_overrides ||= {})[flag_name] = false
  end

  def with(flag_name, value)
    old = Current.feature_overrides&.dig(flag_name)
    value ? enable!(flag_name) : disable!(flag_name)
    yield
  ensure
    old.nil? ? Current.feature_overrides&.delete(flag_name) : Current.feature_overrides[flag_name] = old
  end

  private

  def cast_boolean(value) = ActiveModel::Type::Boolean.new.cast(value)
  def creds_option(flag, suffix) = Rails.app.creds.option(:features, :"#{flag}#{suffix}", default: nil)
  def rollout_percentage(flag) = creds_option(flag, :_percentage)&.to_i
  def allowlist(flag) = creds_option(flag, :_users) || []
  def segments(flag) = creds_option(flag, :_segments) || []

  def allowlisted?(flag, user)
    list = allowlist(flag)
    list.include?(user.id) || list.include?(user.email)
  end

  def segment_match?(flag, user)
    segments(flag).any? { |s| user.try(:"#{s}?") }
  end

  def percentage_match?(flag, user)
    Digest::MD5.hexdigest("#{flag}-#{user.id}").first(8).to_i(16) % 100 < rollout_percentage(flag)
  end
end

Pourquoi Ceci Bat une Solution SaaS

FonctionnalitéCette approcheSaaS
CoûtGratuit$20-500/mois
LatenceZéro (en mémoire)Appel API
Contrôle de versionOui (credentials)Généralement non
Revue de codeOui (PRs)Dépend
Fonctionne hors ligneOuiNon
ComplexitéFaibleMoyenne-Haute
Ciblage utilisateurBasiqueAvancé
AnalyticsDIYIntégré

Pour la plupart des apps, vous n’avez pas besoin de mises à jour de flags en temps réel ni de règles de ciblage complexes. Vous avez besoin de flags faciles à gérer, qui n’ajoutent pas de latence, et qui ne coûtent pas d’argent.

Quand Utiliser un Vrai Service de Feature Flags

Cette approche a des limites. Considérez un service dédié quand vous avez besoin de :

  • Utilisateurs non techniques gérant les flags (product managers, etc.)
  • Règles de ciblage complexes (géographie, type d’appareil, etc.)
  • Mises à jour en temps réel sans déploiement
  • Expérimentation intégrée et analytics A/B test
  • Logs d’audit et fonctionnalités de conformité

Mais pour la plupart des apps Rails ? 46 lignes de code et Rails.app.creds c’est tout ce dont vous avez besoin.

Conclusion

Rails.app.creds de Rails 8.2 avec CombinedConfiguration est une base parfaite pour les feature flags :

  1. Stockez les défauts dans les credentials chiffrées (versionné)
  2. Surchargez avec ENV (pas de déploiement nécessaire)
  3. Surchargez par requête (test, outils admin)
  4. Ajoutez des déploiements par pourcentage et ciblage utilisateur au besoin

Pas de dépendances externes. Pas de latence API. Pas de facture mensuelle.

Voir le PR #56404 pour l’implémentation de Rails.app.creds.