class FeatureFlag < ApplicationRecord
validates :key, presence: true, uniqueness: true
validates :percentage,
numericality: { only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100 }
scope :by_key, ->(key) { find_by(key: key.to_s) }
def enabled_for?(actor)
return false unless enabled?
return false if percentage.zero?
return true if percentage >= 100
actor_id = actor.respond_to?(:id) ? actor.id : actor
return false if actor_id.blank?
bucket_for(actor_id) < percentage
end
private
def bucket_for(actor_id)
seed = "#{key}:#{actor_id}"
Digest::SHA256.hexdigest(seed).to_i(16) % 100
end
end
class FeatureGate
CACHE_TTL = 30.seconds
class << self
def on?(key, actor)
flag = fetch(key)
return false if flag.nil?
flag.enabled_for?(actor)
end
def bust(key)
Rails.cache.delete(cache_key(key))
end
private
def fetch(key)
Rails.cache.fetch(cache_key(key), expires_in: CACHE_TTL) do
FeatureFlag.by_key(key)
end
end
def cache_key(key)
"feature_flag/#{key}"
end
end
end
module FeatureGating
extend ActiveSupport::Concern
included do
helper_method :feature_on?
end
class_methods do
def require_feature(key, **before_action_opts)
before_action(**before_action_opts) do
next if feature_on?(key)
raise ActionController::RoutingError, "Not Found"
end
end
end
def feature_on?(key)
FeatureGate.on?(key, current_user)
end
end
class BetaDashboardController < ApplicationController
include FeatureGating
before_action :authenticate_user!
require_feature :new_dashboard, only: %i[show]
def show
@widgets = current_user.dashboard_widgets.ordered
render :show
end
end
Percentage rollouts let a team ship a feature to a fraction of users and ramp it up without redeploying. The naive approach — rand < percentage — is wrong because it re-rolls the dice on every request, so a user flickers in and out of the feature. The correct pattern is stable bucketing: hash a stable identifier into a fixed range and compare that bucket against the rollout percentage, so a given user's membership only changes when the percentage crosses their bucket.
In FeatureFlag model, each flag is a durable row keyed by a unique key, with an integer percentage (0–100) and an enabled boolean master switch. The core logic lives in enabled_for?, which short-circuits when the flag is off or fully rolled out, then calls bucket_for to derive a deterministic value. bucket_for salts the actor id with the flag key and runs it through Digest::SHA256, taking the number modulo 100. Salting with the key is important: without it the same user would land in the same bucket for every flag, so early adopters of one feature would always be early adopters of everything. The by_key scope and validations keep lookups and data clean.
Because flags are read on nearly every request, FeatureGate service wraps FeatureFlag.by_key in Rails.cache.fetch with a short TTL so the database isn't hit constantly, while still letting percentage changes propagate within seconds. It exposes a simple on?(key, actor) predicate and a bust helper to clear the cache after an admin edits a flag. Fetching a missing flag returns a safe false default rather than raising.
FeaturesController concern mixes require_feature and feature_on? into controllers. require_feature acts as a before_action-style gate that renders 404 when the current user isn't in the rollout, hiding the feature's existence entirely. feature_on? is exposed as a helper_method so views can conditionally render UI. The trade-off of this design is that bucketing is only as stable as the actor id, and anonymous users need a persistent surrogate id; the pattern also assumes uniform hash distribution, which SHA256 provides in practice. It's the right tool for gradual canary releases and kill-switch style flags.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.