LaunchKit

Rails feature flags without a gem

September 18, 2026

A flag is a branch in a program that somebody who is not a programmer gets to steer. That framing decides most of the design: the branch belongs in code, where it is reviewed and tested, and the steering belongs in a table, where flipping it is a form submission rather than a deploy.

The registry is a constant, not a table

class Feature
  Definition = Data.define(:key, :default)

  REGISTRY = [
    Definition.new("ai", true),
    Definition.new("referrals", true),
    Definition.new("blog", true),
    Definition.new("api", true),
    Definition.new("support", true),
    Definition.new("signup", true)
  ].freeze

  KEYS = REGISTRY.map(&:key).freeze
end

Data.define arrived in Ruby 3.2 and is the right shape here for a reason that only shows up later: a Data instance has no setters and reports frozen? == true the moment it is built. A definition cannot be mutated by the code that reads it, which matters when the same array is shared across every request in a multi-threaded server. A Struct would have given the same readers and a way to corrupt them.

Keeping this list in code rather than in a features table is the decision that shapes everything else. A flag is a fork in the program, and the code on both sides of the fork has to exist before the flag means anything. A row in a table can be created by anyone with admin access, and a flag nothing branches on is a switch wired to nothing. Making the registry a constant means the only way to add a flag is a commit, which is the same bar as adding the if it controls.

Overrides live in one jsonb column

The settings row carries a features jsonb column, and the admin writes into it one key at a time:

new_value = Feature.disabled?(definition.key)
Setting.current.update!(features: Setting.current.features.merge(definition.key => new_value))
Setting.reset_cache!

One column rather than one row per flag, and one PATCH per toggle rather than a form with six checkboxes and a Save button. Both of those are small decisions with a shared reason: there is no state in which you meant to flip two flags at once, and a global Save is a control that can apply a change nobody made, because it writes every field it can see including the ones that were stale when the page was rendered.

The column starts empty. An installation nobody has configured has {} in there and behaves exactly as the registry says, which is what makes the defaults meaningful rather than decorative.

Resolving the two

def enabled?(key)
  definition = find(key)
  return false unless definition

  override = Setting.current.features[definition.key]
  override.nil? ? definition.default : ActiveModel::Type::Boolean.new.cast(override)
end

Four lines, and two of them are about being wrong safely.

return false unless definition decides what an unrecognised key means. A gate reading Feature.enabled?(:referals) with one r missing gets false, and the feature stays shut. The other choice, treating an unknown key as on, turns the same typo into a gate that never closes and a flag the admin cannot see or flip. Both are silent. Only one of them fails toward the safer side.

override.nil? rather than override.blank? is doing work too. false is blank, so blank? would send an explicit "off" back to the definition's default of "on", and the toggle would appear to do nothing at all. The check has to distinguish "no opinion stored" from "stored as off", and nil? is the only predicate that does.

The string that is not false

ActiveModel::Type::Boolean.new.cast(override) looks like defensive noise until you follow where the value comes from. A form posts strings. A jsonb column stores whatever it is handed, including "false". And in Ruby the string "false" is an object, so it is truthy:

"false" ? "on" : "off"   # => "on"

Drop the cast, store "false" once, and the feature reads as enabled forever while the admin shows it as off. Nothing raises, and the two screens disagree with no error anywhere to explain it.

ActiveModel::Type::Boolean::FALSE_VALUES is the table doing the work, and it is a set rather than a guess: false, 0, "0", "f", "false", "off", plus the symbol and uppercase spelling of each. Everything not in that set casts to true. Using it here means the flag agrees with the rest of the framework about what a falsy string is instead of inventing a second answer.

One value is not in the table and does not cast to true either. cast("") returns nil, because the type checks for blank before it consults the set. A flag whose override is an empty string is therefore falsy without ever being false, which is harmless at a call site writing if Feature.enabled?(:ai) and wrong at one writing == false.

One query, not a hundred

Feature.enabled? reads Setting.current, and a rendered page can ask about a flag a dozen times: the sidebar, the pricing table, a mailer, two partials. Each of those would be a query if the settings row were fetched each time.

def self.current
  Current.setting ||= first_or_create!
end

def self.reset_cache!
  Current.setting = nil
end

Current is an ActiveSupport::CurrentAttributes subclass, so the memo is scoped to the request and thread rather than to the process. The Rails executor resets it at the request boundary, which gives the property that actually matters in production: flipping a flag in the admin takes effect on the next request in every Puma worker and every background job, with no cache to invalidate across processes and nothing to restart.

The cost is one row read per request that touches a flag, on a table with one row. That is a real cost and a small one, and it is the honest version of "no gem": the gem would have cached it harder and given you a cache to be wrong about.

A left to right flow with two checks. A white box reading Feature.enabled? open paren colon ai close paren leads to a white box asking In REGISTRY, subtitled a frozen list in code. Its No branch drops to a white box reading return false, subtitled a typo closes the feature. Its Yes branch continues right to a white box asking Override stored, subtitled the features jsonb column, and that box forks downward into two: a green one labelled None reading the default, subtitled everything ships on, and a yellow one labelled Stored reading cast it, subtitled quote false quote is truthy. A caption reads: two ways to be wrong here, and neither of them raises anything, a key nobody registered and a string nobody cast.

Where this design stops

Every flag here is one boolean for the whole installation. There is no way to enable the AI layer for your own account and nobody else's, no percentage rollout, no cohort, and no record of who flipped what and when.

Reaching for any of those is not an extension of this code, it is a replacement. A per-user answer means enabled? takes an actor, which changes every call site including the ones inside views that have no actor handy. It means the answer can differ twice inside one request, so the request-scoped memo becomes wrong rather than merely stale. And it means the storage grows a dimension, so a single jsonb column of booleans stops being the shape of the data.

Six global toggles shipped to somebody who will run the product themselves is a different problem from running experiments on your own traffic, and this solves the first one. The gate that enforces these answers is its own decision: a disabled feature that answers 404 covers what the controller does with a false, and why the answer is a status code rather than a redirect.

What this page does not cover

Flags that gate a database migration or a background job rather than a request. The registry works the same way there, but the interesting questions are different ones: a job already enqueued when the flag flips, and a migration that half of your workers have and half do not.

Nor does it cover killing a feature permanently. A flag that has been off in production for six months is not configuration any more, it is dead code with a switch on it, and removing it is a commit that deletes the definition, the gate, the admin label and the branch. Nothing here reminds you to do that.

More on Feature flags in Rails

← All Feature flags in Rails articles