Ruby on Rails has always had a complicated relationship with feature flags. The framework's convention-over-configuration philosophy, combined with Ruby's metaprogramming capabilities, makes it trivially easy to add flags everywhere---views, controllers, models, concerns, jobs, mailers, and middleware. Rails developers can wire up a feature flag in minutes. The problem is that removing one takes hours, because the flag has silently spread across every layer of the MVC stack.
In our experience, mature Rails apps tend to accumulate a significant number of feature flag references, and most of them point to flags that have been fully enabled for months. Each stale flag leaves behind dead view partials, unused controller actions, orphaned model scopes, and test fixtures that nobody dares delete because "it might still be used somewhere."
Rails makes this problem uniquely challenging. Ruby's dynamic dispatch, method_missing overrides, and concern-based composition create flag patterns that are invisible to grep and resistant to static analysis. A flag check in a concern gets mixed into a dozen models. A flag-controlled partial is rendered by a helper that is called from a layout that wraps 40 different views. The blast radius of a single flag in a Rails app is wider than in almost any other framework.
The Rails feature flag landscape
Flipper: The Rails-native standard
Flipper is by far the most popular feature flag library in the Rails ecosystem, and for good reason. It provides a clean Ruby API, ships with an ActiveRecord adapter for persistence, and includes a Rack middleware dashboard for non-technical users to toggle flags.
Basic Flipper setup:
# Gemfile
gem 'flipper'
gem 'flipper-active_record'
gem 'flipper-ui'
# config/initializers/flipper.rb
Flipper.configure do |config|
config.default do
adapter = Flipper::Adapters::ActiveRecord.new
Flipper.new(adapter)
end
end
# Mount the UI dashboard
# config/routes.rb
Rails.application.routes.draw do
mount Flipper::UI.app(Flipper) => '/flipper', as: :flipper
end
Flipper's gate types define how flags are evaluated:
# Boolean gate: on/off for everyone
Flipper.enable(:new_checkout)
Flipper.disable(:new_checkout)
# Actor gate: specific users or objects
Flipper.enable_actor(:beta_dashboard, current_user)
Flipper.enable_actor(:beta_dashboard, Organization.find(42))
# Group gate: predefined groups
Flipper.register(:admins) do |actor|
actor.respond_to?(:admin?) && actor.admin?
end
Flipper.enable_group(:internal_tools, :admins)
# Percentage of actors gate: gradual rollout
Flipper.enable_percentage_of_actors(:new_search, 25)
# Percentage of time gate: random sampling
Flipper.enable_percentage_of_time(:experimental_feature, 10)
Using flags in controllers and views:
# app/controllers/dashboards_controller.rb
class DashboardsController < ApplicationController
def show
if Flipper.enabled?(:analytics_v2, current_user)
@metrics = AnalyticsV2Service.new(current_user).compute
render :show_v2
else
@metrics = LegacyAnalyticsService.new(current_user).compute
render :show
end
end
end
<%# app/views/dashboards/_sidebar.html.erb %>
<nav class="sidebar">
<% if Flipper.enabled?(:new_navigation, current_user) %>
<%= render 'shared/sidebar_v2' %>
<% else %>
<%= render 'shared/sidebar_legacy' %>
<% end %>
<% if Flipper.enabled?(:activity_feed, current_user) %>
<%= render 'dashboards/activity_feed' %>
<% end %>
</nav>
Unleash Ruby SDK
Unleash provides a self-hosted or managed feature flag service with a Ruby client that integrates with Rails through an initializer:
# Gemfile
gem 'unleash', '~> 5.0'
# config/initializers/unleash.rb
Unleash.configure do |config|
config.app_name = Rails.application.class.module_parent_name
config.url = ENV.fetch('UNLEASH_API_URL')
config.custom_http_headers = {
'Authorization' => ENV.fetch('UNLEASH_API_KEY')
}
config.refresh_interval = 15
config.metrics_interval = 60
config.retry_limit = 3
end
UNLEASH = Unleash::Client.new
# app/services/pricing_service.rb
class PricingService
def calculate(user, plan)
context = Unleash::Context.new(
user_id: user.id.to_s,
properties: {
'plan' => user.current_plan.name,
'country' => user.country_code,
'account_age_days' => user.account_age_in_days.to_s
}
)
if UNLEASH.is_enabled?('dynamic-pricing', context)
DynamicPricingEngine.new.calculate(user, plan)
else
StaticPricingTable.lookup(plan)
end
end
end
Unleash strategies map well to Rails use cases:
# Custom strategy for Rails organizations
class OrganizationStrategy < Unleash::Strategy::Base
def name
'organizationId'
end
def is_enabled?(params = {}, context = nil)
return false unless context&.properties&.key?('organization_id')
allowed_orgs = (params['organizationIds'] || '').split(',')
allowed_orgs.include?(context.properties['organization_id'])
end
end
# Register the strategy
Unleash.configure do |config|
config.strategies.add(OrganizationStrategy.new)
end
Custom implementation with Rails configuration
Many Rails teams build their own flag system, especially when the requirement is simple boolean toggles without gradual rollout:
# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
validates :key, presence: true, uniqueness: true
scope :enabled, -> { where(enabled: true) }
scope :stale, -> {
where(enabled: true)
.where('updated_at < ?', 90.days.ago)
}
def self.enabled?(key, actor = nil)
flag = find_by(key: key)
return false unless flag&.enabled?
if flag.actor_ids.present? && actor
flag.actor_ids.include?(actor.id)
else
true
end
end
end
# app/controllers/concerns/feature_flaggable.rb
module FeatureFlaggable
extend ActiveSupport::Concern
included do
helper_method :feature_enabled?
end
private
def feature_enabled?(key)
FeatureFlag.enabled?(key, current_user)
end
end
This approach gives full control, but it creates the worst cleanup debt of the three options. Flag checks are scattered across the codebase with no central SDK surface to search for. The feature_enabled? helper method can be called from any controller, view, helper, or concern, and custom implementations rarely include any metadata about flag purpose, owner, or expected lifetime.
Rails-specific flag patterns and pitfalls
Flags in ActiveRecord callbacks
One of the most dangerous places to put a feature flag in a Rails app is inside an ActiveRecord callback:
# app/models/order.rb
class Order < ApplicationRecord
after_create :send_confirmation
private
def send_confirmation
if Flipper.enabled?(:transactional_email_v2)
TransactionalEmailV2Job.perform_later(self)
else
OrderMailer.confirmation(self).deliver_later
end
end
end
This pattern is dangerous because the flag is evaluated on every order creation, but there is no actor context---Flipper.enabled? without an actor falls back to the boolean gate. If the flag is partially rolled out (percentage of actors), this code silently uses the boolean gate, which may not match the intended rollout strategy.
Worse, when the flag becomes stale, the callback remains and nobody cleans it up because callbacks are the part of a Rails model that developers are most reluctant to touch. "It works, don't touch it" is the unofficial motto of the after_create block in production Rails apps.
Flags in concerns and mixins
Ruby's module system lets you mix behavior into classes, which is powerful but creates wide blast radius for flags:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
include Elasticsearch::Model if Flipper.enabled?(:elasticsearch)
scope :search, ->(query) {
if Flipper.enabled?(:elasticsearch)
__elasticsearch__.search(query).records
else
where('name ILIKE ?', "%#{query}%")
end
}
end
class_methods do
def reindex!
if Flipper.enabled?(:elasticsearch)
__elasticsearch__.create_index!(force: true)
__elasticsearch__.import
else
Rails.logger.info("Elasticsearch disabled, skipping reindex")
end
end
end
end
If Searchable is included in Product, Article, User, and Organization, a single flag controls search behavior across four models. When the flag is ready for cleanup, you need to trace every model that includes the concern, update the concern itself, and verify that the Elasticsearch-specific code is the correct permanent behavior for all four models.
Flags in Rails engines and mountable apps
Teams that use Rails engines for modular architecture face additional flag complexity:
# engines/billing/app/controllers/billing/subscriptions_controller.rb
module Billing
class SubscriptionsController < Billing::ApplicationController
def create
if Flipper.enabled?(:stripe_checkout_v2, current_user)
session = Stripe::Checkout::Session.create(
mode: 'subscription',
line_items: build_line_items_v2(params),
success_url: success_url,
cancel_url: cancel_url
)
redirect_to session.url, allow_other_host: true
else
@subscription = current_organization.subscriptions.build(
subscription_params
)
if @subscription.save
redirect_to @subscription
else
render :new, status: :unprocessable_entity
end
end
end
end
end
The flag lives inside an engine, but Flipper is configured in the host application. The engine's tests may not have access to the same Flipper configuration, leading to tests that only exercise one branch. When the flag is cleaned up, the change spans the engine and potentially the host app's test suite.
Flags in background jobs
Sidekiq and ActiveJob workers present a timing issue with feature flags:
# app/jobs/report_generation_job.rb
class ReportGenerationJob < ApplicationJob
queue_as :reports
def perform(organization_id, report_type)
org = Organization.find(organization_id)
if Flipper.enabled?(:parallel_reports, org)
ParallelReportGenerator.new(org, report_type).generate
else
SequentialReportGenerator.new(org, report_type).generate
end
end
end
The flag is evaluated at job execution time, not at enqueue time. If a flag is toggled while jobs are queued, some jobs will execute with the old behavior and some with the new. For most flags this is fine, but for flags that control data format, storage location, or external API version, mid-queue flag changes can cause subtle data inconsistencies.
View fragment caching and flags
Rails' fragment caching interacts poorly with feature flags:
<%# app/views/products/show.html.erb %>
<% cache product do %>
<h1><%= product.name %></h1>
<% if Flipper.enabled?(:rich_product_cards, current_user) %>
<%= render 'products/rich_card', product: product %>
<% else %>
<%= render 'products/basic_card', product: product %>
<% end %>
<% end %>
The cache key is based on product, but the rendered content depends on the feature flag. User A with the flag enabled will see the rich card, and Rails will cache it. User B without the flag will get User A's cached rich card. The fix is to include the flag state in the cache key, but this is often forgotten:
<%# Correct: include flag state in cache key %>
<% cache [product, Flipper.enabled?(:rich_product_cards, current_user)] do %>
<%# ... %>
<% end %>
When the flag is eventually cleaned up, developers must also remove it from every cache key---and stale cache keys with the old flag value will persist until they expire or are explicitly purged.
Why Rails apps accumulate flag debt faster
Rails has several properties that accelerate flag debt accumulation compared to other frameworks:
1. Convention-driven file proliferation. A single flag in a Rails resource often touches the controller, two or more view templates, a partial, a helper, a model scope, a job, and a mailer. That is 7+ files for one flag. In a React SPA with an API backend, the same flag might touch 2-3 files.
2. Ruby's permissive type system. Ruby will not complain if a flag-controlled method returns a Hash on one branch and an OpenStruct on the other. Consumers of the method handle both silently, and the dead branch's return type lives on in downstream code long after the flag is cleaned up.
3. Metaprogramming obscures flag usage. Dynamic method definition, method_missing, and define_method can create flag-controlled behavior that is invisible to any text search:
# app/models/concerns/feature_toggled.rb
module FeatureToggled
extend ActiveSupport::Concern
class_methods do
def feature_method(name, flag:, &block)
define_method(name) do |*args|
if Flipper.enabled?(flag, self)
instance_exec(*args, &block)
else
super(*args)
end
end
end
end
end
# app/models/user.rb
class User < ApplicationRecord
include FeatureToggled
feature_method :display_name, flag: :full_name_display do
"#{first_name} #{last_name}"
end
end
Searching for :full_name_display will find the feature_method call in the model, but will not reveal that this flag controls the display_name method or that there is a super fallback somewhere in the inheritance chain. This level of indirection is common in mature Rails codebases, and it makes flag cleanup far more complex than the initial implementation.
4. Database-backed flags resist git-based tracking. Flipper with ActiveRecord stores flag state in the database, not in source code. A flag can be enabled in production, disabled in staging, and partially rolled out in development, with no trace in the git history. Code that checks the flag exists in source control, but the flag's lifecycle (created, enabled, fully rolled out, ready for cleanup) lives in production database records that are not version-controlled.
Detection and cleanup strategies for Rails
Scanning Ruby with tree-sitter
Tree-sitter provides the most accurate detection of flag references in Ruby code because it understands the AST rather than matching text patterns:
# Rake task for flag detection
# lib/tasks/flags.rake
namespace :flags do
desc 'List all feature flag references in the codebase'
task audit: :environment do
flag_pattern = /Flipper\.enabled\?\(:(\w+)/
unleash_pattern = /UNLEASH\.is_enabled\?\('([^']+)'/
custom_pattern = /feature_enabled\?\(:(\w+)/
results = Hash.new { |h, k| h[k] = [] }
Dir.glob(Rails.root.join('app/**/*.{rb,erb}')) do |file|
content = File.read(file)
relative = Pathname.new(file).relative_path_from(Rails.root)
[flag_pattern, unleash_pattern, custom_pattern].each do |pattern|
content.scan(pattern).flatten.each do |flag_key|
results[flag_key] << {
file: relative.to_s,
line: content[0..content.index(flag_key)].count("\n") + 1
}
end
end
end
results.sort_by { |_, refs| -refs.size }.each do |key, refs|
puts "\n#{key} (#{refs.size} references):"
refs.each { |r| puts " #{r[:file]}:#{r[:line]}" }
end
end
end
This rake task catches the common patterns, but misses metaprogrammed flags, flags passed as variables, and flags evaluated through custom wrapper methods. For production-grade detection, tree-sitter parsing handles the edge cases that regex cannot---multi-line method calls, interpolated flag keys, and flags wrapped in custom DSLs.
FlagShark uses tree-sitter to detect feature flag references across Ruby files, handling Flipper, Unleash, and custom flag implementations. It tracks each flag from the pull request that introduces it through to the cleanup PR that removes it, catching references that simple text search would miss.
Building a cleanup workflow
A practical Rails flag cleanup workflow has three phases:
Phase 1: Identify stale flags
# lib/tasks/flags.rake
namespace :flags do
desc 'List stale flags (enabled > 90 days, 100% rollout)'
task stale: :environment do
Flipper.features.each do |feature|
next unless feature.boolean_value # Fully enabled
# Check how long it has been fully enabled
last_change = Flipper::Adapters::ActiveRecord::Gate
.where(feature_key: feature.key)
.order(updated_at: :desc)
.first
next unless last_change
age_days = (Time.current - last_change.updated_at).to_i / 1.day
next unless age_days > 90
references = `grep -r "#{feature.key}" app/ config/ spec/ \
--include="*.rb" --include="*.erb" -l`.strip.split("\n")
puts "#{feature.key}: enabled #{age_days} days, " \
"#{references.size} file references"
references.each { |f| puts " #{f}" }
end
end
end
Phase 2: Cleanup the code
For each stale flag, the cleanup involves:
- Remove the conditional, keeping only the enabled branch
- Remove the disabled branch's code (dead view partials, unused service classes)
- Remove the flag from cache keys
- Remove flag-specific test cases (keeping tests for the enabled behavior)
- Remove the flag from Flipper (database cleanup)
# Before cleanup
class CheckoutController < ApplicationController
def create
if Flipper.enabled?(:stripe_checkout_v2, current_user)
session = StripeCheckoutV2Service.new(current_user).create(
cart: current_cart,
success_url: checkout_success_url,
cancel_url: checkout_cancel_url
)
redirect_to session.url, allow_other_host: true
else
@order = LegacyCheckoutService.new(current_user).process(
current_cart
)
if @order.persisted?
redirect_to order_path(@order)
else
render :new, status: :unprocessable_entity
end
end
end
end
# After cleanup (flag permanently enabled, legacy code removed)
class CheckoutController < ApplicationController
def create
session = StripeCheckoutV2Service.new(current_user).create(
cart: current_cart,
success_url: checkout_success_url,
cancel_url: checkout_cancel_url
)
redirect_to session.url, allow_other_host: true
end
end
Phase 3: Verify and deploy
After removing the flag code:
# Run the full test suite
bundle exec rspec
# Check for any remaining references
grep -r "stripe_checkout_v2" app/ config/ spec/ lib/
# Verify no broken view renders
bundle exec rails runner "ApplicationController.subclasses.each(&:name)"
# Remove the flag from Flipper
bundle exec rails runner "Flipper.remove(:stripe_checkout_v2)"
Preventing flag accumulation
The best cleanup strategy is prevention. Add guardrails that make flag creation deliberate and flag cleanup inevitable:
1. Flag registration with metadata:
# config/initializers/feature_flags.rb
REGISTERED_FLAGS = {
new_checkout: {
description: 'Stripe Checkout v2 integration',
owner: 'payments-team',
created: '2026-01-15',
expected_cleanup: '2026-04-15',
jira: 'PAY-1234'
},
parallel_reports: {
description: 'Parallel report generation',
owner: 'platform-team',
created: '2026-02-01',
expected_cleanup: '2026-05-01',
jira: 'PLAT-567'
}
}.freeze
# Validate that all used flags are registered
Rails.application.config.after_initialize do
if Rails.env.development? || Rails.env.test?
unregistered = Flipper.features.map(&:key).map(&:to_sym) -
REGISTERED_FLAGS.keys
if unregistered.any?
Rails.logger.warn(
"Unregistered feature flags: #{unregistered.join(', ')}"
)
end
end
end
2. Expiration warnings in development:
# config/initializers/flag_expiration.rb
if Rails.env.development?
Rails.application.config.after_initialize do
REGISTERED_FLAGS.each do |key, meta|
cleanup_date = Date.parse(meta[:expected_cleanup])
if Date.current > cleanup_date
Rails.logger.warn(
"\e[33m[FLAG OVERDUE] :#{key} was due for cleanup " \
"on #{meta[:expected_cleanup]} " \
"(#{(Date.current - cleanup_date).to_i} days overdue)\e[0m"
)
elsif Date.current > cleanup_date - 14.days
Rails.logger.info(
"[FLAG EXPIRING] :#{key} cleanup due " \
"#{meta[:expected_cleanup]}"
)
end
end
end
end
3. RuboCop custom cop for flag hygiene:
# lib/rubocop/cop/custom/feature_flag_in_callback.rb
module RuboCop
module Cop
module Custom
class FeatureFlagInCallback < Base
MSG = 'Avoid feature flag checks inside ActiveRecord ' \
'callbacks. Extract to a service object.'
CALLBACK_METHODS = %i[
before_save after_save before_create after_create
before_update after_update before_destroy after_destroy
].freeze
def_node_matcher :flipper_call?, <<~PATTERN
(send (const nil? :Flipper) :enabled? ...)
PATTERN
def on_send(node)
return unless inside_callback?(node)
return unless flipper_call?(node)
add_offense(node)
end
private
def inside_callback?(node)
node.each_ancestor(:block).any? do |block|
CALLBACK_METHODS.include?(
block.method_name
)
end
end
end
end
end
end
Testing strategies for flagged Rails code
Testing both branches
Every flagged code path needs test coverage for both the enabled and disabled states:
# spec/controllers/dashboards_controller_spec.rb
RSpec.describe DashboardsController, type: :controller do
let(:user) { create(:user) }
before { sign_in(user) }
describe 'GET #show' do
context 'with analytics_v2 enabled' do
before { Flipper.enable(:analytics_v2) }
it 'renders the v2 template' do
get :show
expect(response).to render_template(:show_v2)
end
it 'uses the v2 analytics service' do
expect(AnalyticsV2Service).to receive(:new).with(user)
get :show
end
end
context 'with analytics_v2 disabled' do
before { Flipper.disable(:analytics_v2) }
it 'renders the legacy template' do
get :show
expect(response).to render_template(:show)
end
it 'uses the legacy analytics service' do
expect(LegacyAnalyticsService).to receive(:new).with(user)
get :show
end
end
end
end
Flipper test helpers
Flipper provides test helpers that make flag testing cleaner:
# spec/support/flipper.rb
RSpec.configure do |config|
config.before(:each) do
Flipper.features.each(&:remove)
end
end
# Or use Flipper's test adapter for isolated tests
RSpec.configure do |config|
config.before(:each) do
Flipper.instance = Flipper.new(Flipper::Adapters::Memory.new)
end
end
The test maintenance trap
Here is the problem: when a flag is permanently enabled, the "disabled" test branch becomes dead test code. It still runs, still passes, still consumes CI time, and still gets maintained when the test file is refactored. In a Rails app with 50 stale flags and two test cases per flag, that is 50 dead test blocks wasting CI minutes and developer attention on every test suite run.
Track your test-to-flag ratio as a health metric. If your spec suite has more feature flag contexts than active flags, you have test debt from flag accumulation.
Automated detection across your Rails codebase
The rake tasks and grep commands shown above work for small codebases, but they break down at scale. They miss flags in ERB templates with complex interpolation, flags passed through helper methods, and flags in engine code that lives in a separate directory structure. They also require manual execution---someone has to remember to run the audit, and someone has to act on the results.
For teams that want continuous flag lifecycle tracking integrated into their pull request workflow, FlagShark connects to GitHub repositories and automatically detects flag additions and removals across Ruby files using tree-sitter parsing. It understands Flipper's enabled? API, Unleash's is_enabled? calls, and custom flag methods, tracking each flag from the PR that introduces it through the PR that removes it. When flags have been fully enabled beyond a configurable threshold, it can automatically generate the cleanup pull request.
Key takeaways
Feature flags in Ruby on Rails are a force multiplier for safe deployments---and a force multiplier for technical debt when left uncleaned. The framework's flexibility means flags spread across more layers than in most other frameworks, and Ruby's dynamic nature means stale flags hide more effectively.
The practical rules for Rails teams:
- Choose Flipper or Unleash over custom implementations. A consistent SDK surface makes automated detection possible and cleanup predictable.
- Never put flags inside ActiveRecord callbacks. Extract flagged behavior to service objects where the conditional logic is explicit and testable.
- Include flag state in cache keys. Fragment caching and feature flags interact in ways that cause silent, hard-to-diagnose bugs.
- Register every flag with an owner and expiration date. The cost of 30 seconds of registration is nothing compared to the cost of a flag that lingers for a year.
- Track your cleanup velocity. If you are creating flags faster than you are removing them, your Rails app is on a trajectory toward the kind of conditional complexity that makes even simple changes risky.
Rails convention says there should be a clear, predictable place for everything. Feature flags that have served their purpose do not belong in that picture. Clean them up.