Construye un Sistema de Feature Flags en 50 Líneas con Rails.app.creds

No necesitas LaunchDarkly. No necesitas Flipper. Para la mayoría de las aplicaciones Rails, necesitas feature flags que estén controlados por versiones, sean fáciles de sobrescribir en desarrollo, y no requieran otro servicio para administrar.

Rails.app.creds de Rails 8.2 te da exactamente eso. Aquí tienes un sistema completo de feature flags en menos de 50 líneas de código.

La Idea Central

Rails.app.creds primero verifica ENV, luego las credenciales encriptadas. Esto significa:

  1. Almacena tus flags en credentials.yml.enc (controlado por versiones, revisado en PRs)
  2. Sobrescribe con variables ENV en cualquier momento (testing, debugging, rollouts graduales)

Sin base de datos. Sin servicio externo. Sin llamadas API.

Paso 1: Almacena Flags en Credentials

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

Estos son tus valores por defecto. Están encriptados, controlados por versiones, y requieren un PR para cambiar.

Paso 2: Crea el Módulo de 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

Paso 3: Añade Sobrescrituras con Alcance por Request

Para control de flags por request (testing, sobrescrituras de admin), conecta 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

Actualiza la cadena de credenciales:

# config/initializers/credentials.rb
Rails.app.creds = ActiveSupport::CombinedConfiguration.new(
  FeatureOverrideConfiguration.new,  # Alcance por request primero
  Rails.app.envs,                     # Luego ENV
  Rails.app.credentials               # Luego archivo encriptado
)

Paso 4: Úsalo en Todas Partes

En controladores

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

En vistas

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

En modelos

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

En tests

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

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

Sobrescribiendo Flags

En desarrollo

Sobrescribe cualquier flag sin editar credentials:

# Habilitar un flag
FEATURES__NEW_CHECKOUT=true bin/rails server

# Habilitar múltiples
FEATURES__NEW_CHECKOUT=true FEATURES__DARK_MODE=true bin/rails server

En staging/producción

Configura variables ENV en tu configuración de despliegue:

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

Por request (herramientas de 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

Avanzado: Rollouts por Porcentaje

¿Quieres habilitar una funcionalidad para el 10% de los usuarios?

# app/models/concerns/feature_flags.rb
module FeatureFlags
  def enabled_for?(flag_name, user)
    # Verificar si está explícitamente habilitado/deshabilitado primero
    return enabled?(flag_name) if explicitly_set?(flag_name)

    # Verificar rollout por porcentaje
    percentage = Rails.app.creds.option(:features, :"#{flag_name}_percentage", default: nil)
    return enabled?(flag_name) unless percentage

    # Bucketing consistente basado en user ID
    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

Configura el rollout:

# credentials.yml.enc
features:
  new_checkout: false
  new_checkout_percentage: 10  # 10% de usuarios

Uso:

if FeatureFlags.enabled_for?(:new_checkout, current_user)
  # 10% de usuarios ven esto
end

El mismo usuario siempre obtiene el mismo resultado (bucketing consistente vía hash MD5).

Avanzado: Segmentación por Usuario

Habilita funcionalidades para segmentos específicos de usuarios:

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
    - "cto@company.com"  # O email
  new_checkout_segments:
    - staff
    - beta
  new_checkout_percentage: 5  # Más 5% de todos los demás

Estrategia de rollout: staff primero, luego beta testers, luego 5% de todos, luego aumentar el porcentaje a medida que crece la confianza.

La Implementación Completa

Aquí está todo en un solo lugar:

# app/models/concerns/feature_flags.rb (46 líneas)
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

Por Qué Esto Supera a una Solución SaaS

CaracterísticaEste enfoqueSaaS
CostoGratis$20-500/mes
LatenciaCero (en memoria)Llamada API
Control de versionesSí (credentials)Usualmente no
Code reviewSí (PRs)Depende
Funciona offlineNo
ComplejidadBajaMedia-Alta
Segmentación de usuariosBásicaAvanzada
AnalyticsDIYIncorporado

Para la mayoría de las apps, no necesitas actualizaciones de flags en tiempo real ni reglas de segmentación complejas. Necesitas flags que sean fáciles de administrar, no añadan latencia, y no cuesten dinero.

Cuándo Usar un Servicio de Feature Flags Real

Este enfoque tiene límites. Considera un servicio dedicado cuando necesites:

  • Usuarios no técnicos administrando flags (product managers, etc.)
  • Reglas de segmentación complejas (geografía, tipo de dispositivo, etc.)
  • Actualizaciones en tiempo real sin deploys
  • Experimentación incorporada y analytics de A/B testing
  • Logs de auditoría y funcionalidades de cumplimiento

¿Pero para la mayoría de las apps Rails? 46 líneas de código y Rails.app.creds es todo lo que necesitas.

Conclusión

Rails.app.creds de Rails 8.2 con CombinedConfiguration es una base perfecta para feature flags:

  1. Almacena defaults en credenciales encriptadas (controlado por versiones)
  2. Sobrescribe con ENV (sin deploy necesario)
  3. Sobrescribe por request (testing, herramientas de admin)
  4. Añade rollouts por porcentaje y segmentación de usuarios según sea necesario

Sin dependencias externas. Sin latencia de API. Sin factura mensual.

Mira el PR #56404 para la implementación de Rails.app.creds.