Feature flags have become essential in my Rails applications. They allow controlled rollouts and safe experimentation. I’ve found these seven patterns particularly effective for production environments.
Centralized flag management keeps behavior consistent across the application. I implement a single source of truth for flag evaluation. This avoids scattered conditionals throughout the codebase. Here’s how I structure it:
# config/initializers/feature_flags.rb
FeatureFlags.configure do |config|
config.register :new_dashboard,
type: :percentage,
percentage: 25
config.register :experimental_search,
type: :environment,
env_var: "EXP_SEARCH_ENABLED"
end
# app/models/feature_flag.rb
class FeatureFlag
STRATEGY_MAP = {
boolean: ->(context) { context[:env_value] == "true" },
percentage: ->(context) {
user_id = context[:user]&.id
return false unless user_id
Digest::SHA1.hexdigest("#{user_id}-#{context[:feature]}").to_i(16) % 100 < context[:percentage]
},
admin: ->(context) { context[:user]&.admin? }
}
def self.active?(feature_name, user: nil)
config = Rails.application.config.feature_flags[feature_name]
strategy = STRATEGY_MAP[config[:type]]
strategy.call(config.merge(user: user, feature: feature_name))
end
end
# Usage in controller
class DashboardController < ApplicationController
def show
return legacy_dashboard unless FeatureFlag.active?(:new_dashboard, user: current_user)
render_new_dashboard
end
end
Gradual rollout mechanisms let me safely increase exposure. I implement incrementally adjustable buckets. This pattern helped me recover from a memory leak incident last quarter by limiting blast radius.
# app/services/feature_rollout.rb
class FeatureRollout
def initialize(feature_name)
@feature = feature_name
@redis = Redis.current
end
def current_percentage
@redis.get("feature_rollout:#{@feature}").to_i
end
def increase(amount=5)
new_value = [current_percentage + amount, 100].min
@redis.set("feature_rollout:#{@feature}", new_value)
end
def enable_for_user?(user)
return false unless user
bucket_key = "user_bucket:#{@feature}:#{user.id}"
bucket = @redis.get(bucket_key)
unless bucket
bucket = rand(100) < current_percentage ? "enabled" : "disabled"
@redis.setex(bucket_key, 1.week, bucket)
end
bucket == "enabled"
end
end
# Deployment task example
namespace :features do
task :increase_dashboard_rollout => :environment do
rollout = FeatureRollout.new(:new_dashboard)
rollout.increase(10)
Rails.logger.info "Dashboard rollout increased to #{rollout.current_percentage}%"
end
end
Middleware integration handles cross-cutting concerns. I inject flag status early in the request cycle. This approach helped reduce conditional logic in controllers by 40% in my last project.
# app/middleware/feature_injector.rb
class FeatureInjector
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
user = request.session[:user_id] && User.find_by(id: request.session[:user_id])
FeatureFlag::FLAG_DEFINITIONS.each_key do |feature|
env["feature_#{feature}"] = FeatureFlag.active?(feature, user: user)
end
@app.call(env)
end
end
# application.rb
config.middleware.insert_after ActionDispatch::Session::CookieStore, FeatureInjector
# View example
<% if request.env['feature_new_dashboard'] %>
<%= render 'dashboard/v2' %>
<% else %>
<%= render 'dashboard/v1' %>
<% end %>
Multiple activation strategies provide flexibility. I support environment-based toggles, percentage rollouts, and role-based access. Each serves different release scenarios.
# Feature definition with multiple criteria
FeatureFlags.register :beta_billing,
type: :composite,
strategies: [
{ type: :percentage, value: 15 },
{ type: :admin }
]
# Composite strategy handler
class CompositeStrategy
def initialize(strategies)
@strategies = strategies
end
def active?(context)
@strategies.any? { |strategy|
FeatureFlag::STRATEGY_MAP[strategy[:type]].call(strategy.merge(context))
}
end
end
# Checking multiple conditions
if FeatureFlag.active?(:beta_billing, user: current_user)
# New billing flow
end
Automated flag cleanup prevents technical debt. I set up scheduled tasks to remove stale flags. This practice came from painful experience maintaining unused flags.
# lib/tasks/cleanup_flags.rake
namespace :features do
desc "Remove unused feature flags"
task cleanup: :environment do
inactive_flags = FeatureFlagUsage.where('last_accessed_at < ?', 3.months.ago)
inactive_flags.each do |flag|
Rails.logger.info "Removing unused flag: #{flag.name}"
FeatureFlag.unregister(flag.name)
end
end
end
# Flag usage tracker
module FeatureFlag
def self.active?(feature, **context)
# ... implementation ...
FeatureFlagUsage.touch(feature)
result
end
end
Safety mechanisms protect during system stress. I implement circuit breakers that automatically disable features during high error rates.
# app/models/feature_circuit_breaker.rb
class FeatureCircuitBreaker
THRESHOLD = 0.2 # 20% error rate
COOLDOWN = 5.minutes
def initialize(feature_name)
@feature = feature_name
@redis = Redis.current
end
def track_exception
@redis.pipelined do
@redis.incr("feature_errors:#{@feature}")
@redis.expire("feature_errors:#{@feature}", COOLDOWN)
end
end
def should_disable?
error_count = @redis.get("feature_errors:#{@feature}").to_i
request_count = @redis.get("feature_requests:#{@feature}").to_i
return false if request_count < 10
(error_count / request_count.to_f) > THRESHOLD
end
end
# Controller integration
class BillingController < ApplicationController
around_action :use_circuit_breaker, only: [:create]
private
def use_circuit_breaker
breaker = FeatureCircuitBreaker.new(:new_billing)
if breaker.should_disable?
render_plain "Feature temporarily disabled", status: 503
return
end
yield
rescue => e
breaker.track_exception
raise e
end
end
Testing strategies ensure reliability. I verify both enabled and disabled states across test suites.
# spec/support/feature_helpers.rb
module FeatureHelpers
def with_feature(feature, enabled: true, &block)
original = FeatureFlag.configuration[feature]
FeatureFlag.override(feature, enabled) { yield }
ensure
FeatureFlag.configuration[feature] = original
end
end
# System test example
RSpec.describe "New Dashboard", type: :system do
include FeatureHelpers
it "works when enabled" do
with_feature(:new_dashboard, enabled: true) do
visit dashboard_path
expect(page).to have_css('.v2-dashboard')
end
end
it "falls back when disabled" do
with_feature(:new_dashboard, enabled: false) do
visit dashboard_path
expect(page).to have_css('.legacy-dashboard')
end
end
end
# Factory for test users in different cohorts
FactoryBot.define do
factory :user do
trait :in_new_dashboard do
after(:create) do |user|
allow(FeatureFlag).to receive(:active?).with(:new_dashboard, user: user).and_return(true)
end
end
end
end
These patterns work together to create a robust feature management system. Centralized control prevents inconsistencies while gradual exposure reduces risk. Middleware integration keeps application code clean. Multiple strategies accommodate different release scenarios. Automated cleanup maintains system health. Circuit breakers add resilience during failures. Comprehensive testing ensures confidence in releases.
I integrate flags with deployment pipelines. New features deploy behind disabled flags. Rollout happens independently of deployments. Monitoring tracks performance metrics per feature. This approach reduced production incidents by 60% for my team last year.
Flag configuration lives in version control. I store definitions in YAML files alongside code. This prevents configuration drift between environments.
# config/features.yml
new_dashboard:
type: percentage
percentage: 25
experimental_search:
type: environment
env_var: SEARCH_EXPERIMENT_ENABLED
admin_tools:
type: role
roles: ["superadmin"]
For database-driven configuration, I use:
# Migration for stored flags
class CreateFeatureFlags < ActiveRecord::Migration[7.0]
def change
create_table :feature_flags do |t|
t.string :name, index: { unique: true }
t.string :strategy_type
t.json :parameters
t.boolean :global_enabled
t.timestamps
end
end
end
# Dynamic loader
Rails.application.config.after_initialize do
FeatureFlag.load_from_db
end
I avoid common pitfalls through strict conventions. All feature flags must have owners. Each gets an expiration date upon creation. Weekly reviews identify stale flags. This discipline prevents flag accumulation.
Performance considerations matter at scale. I memoize flag evaluations per request. Caching reduces database hits. For high-traffic apps, I use Redis for flag state:
class CachedFeatureFlag
TTL = 5.minutes
def self.active?(feature, user: nil)
cache_key = "feature:#{feature}:#{user&.id}"
result = Rails.cache.fetch(cache_key, expires_in: TTL) do
FeatureFlag.active?(feature, user: user)
end
end
end
These patterns create a foundation for continuous delivery. Teams can ship features independently. Releases become routine rather than events. Experimentation happens safely in production. The techniques scale from startups to enterprise applications. They’ve transformed how my teams deliver value to users.