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.
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:
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.
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.
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.
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.
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
Data.definearrived in Ruby 3.2 and is the right shape here for a reason that only shows up later: aDatainstance has no setters and reportsfrozen? == truethe 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. AStructwould have given the same readers and a way to corrupt them.Keeping this list in code rather than in a
featurestable 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 theifit controls.Overrides live in one jsonb column
The settings row carries a
featuresjsonb column, and the admin writes into it one key at a time:One column rather than one row per flag, and one
PATCHper 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
Four lines, and two of them are about being wrong safely.
return false unless definitiondecides what an unrecognised key means. A gate readingFeature.enabled?(:referals)with onermissing getsfalse, 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 thanoverride.blank?is doing work too.falseis blank, soblank?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", andnil?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. Ajsonbcolumn stores whatever it is handed, including"false". And in Ruby the string"false"is an object, so it is truthy: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_VALUESis 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
trueeither.cast("")returnsnil, because the type checks for blank before it consults the set. A flag whose override is an empty string is therefore falsy without ever beingfalse, which is harmless at a call site writingif Feature.enabled?(:ai)and wrong at one writing== false.One query, not a hundred
Feature.enabled?readsSetting.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.Currentis anActiveSupport::CurrentAttributessubclass, 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.
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.