A feature flag turned off has to mean something specific at the HTTP layer, and the choice is made
once for the whole application or it is made inconsistently forever. There are four candidates: 404,
403, a redirect somewhere friendly, or a page that renders with the feature's parts missing.
What each answer tells the visitor
403 says the route exists and you may not have it. A redirect to the dashboard says the same thing
more politely. Rendering an empty page says the feature exists and is broken. 404 says nothing at
all, which is the only answer that does not hand out information.
The information matters more than it first seems, because the person typing URLs at a product is
usually not its customer. A 403 on /api/v1/users confirms there is a JSON API to come back for. A
redirect on /referral confirms a referral program exists and is currently shut. Neither is a
vulnerability, and both are the sort of detail that turns a bored scan into an interested one.
The argument on the other side is real: a signed-in customer who bookmarked a page and now gets 404
has no idea what happened, and a support ticket is the result. That is the cost of this choice, and
it is paid by the small number of people who used the feature before it was switched off. A product
whose flags are flipped weekly should probably answer differently to a signed-in user than to a
stranger. This one answers 404 to both, and the simplicity is the point.
class_methods do is what ActiveSupport::Concern gives you instead of a nested
module ClassMethods and an extend in included do. The **options splat is not decoration
either: it forwards straight into the callback, so gated_by :support, only: :new works because
only: is a callback option and nothing here needs to know that.
head :not_found rather than raise ActiveRecord::RecordNotFound is a deliberate difference. The
exception would route through config.exceptions_app and render the application's 404 page, which
is friendlier and also renders a layout, a navigation bar and whatever else the page carries. The
bare head sends the status and an empty body, which is what a route that does not exist would do.
The word that does the work
prepend_before_action, not before_action. One word, and it decides the answer for every visitor
who is not signed in.
Rails before_action order is not configuration, it is registration order: callbacks run in the
sequence they were declared, and inheritance counts as declaration. A controller like
App::SupportTicketsController inherits an authentication callback from ApplicationController,
registered when that class was defined, which means it is already in the chain before gated_by
runs. A plain before_action appends, so authentication goes first and the
gate never gets a turn for an anonymous request. prepend_before_action puts the gate at the front.
The difference is visible in one request. With the feature on, an anonymous GET on
/app/support_tickets/new answers 302 to /session/new, which is correct: the page exists and
you need an account. With the feature off, the same request answers 404.
Swap the one word and run it again, and the disabled case answers 302 to /session/new as well.
The feature is off, and the application still offers the visitor a sign-in page for it. Everything
else about the code is identical, and nothing fails: the flag works, the gate runs, the module is
unreachable. It just announces itself on the way.
The gate is not the whole feature
Nine controllers in this codebase declare gated_by, covering referrals, sign-up, the support
inbox on both the customer and admin sides, the blog, three AI controllers and the JSON API base
controller. Gating the base controller is worth noticing: every versioned API controller inherits
from it, so one declaration closes the entire namespace rather than one endpoint at a time.
None of that hides a link. The sidebar wraps its entries in feature?(:key), the pricing table
checks the signup flag before rendering a free plan, and a mailer that belongs to a feature has to
check for itself. A flag is enforced in as many places as the feature has surfaces, and the
controller gate is only the one that cannot be worked around by typing.
The reverse mistake is more common and worse. Hiding the link alone leaves the feature fully
functional for anyone with a bookmark, a browser history entry or a link in an old email, which is
precisely the population most likely to use it.
Testing the closed door
The closed case is the one nobody looks at, because while it works there is nothing to see. Six
request specs here assert it through a shared example, and
testing feature flags in Rails
goes through what that example pins down and the one line inside it that turns out to pin nothing.
Where the flag's answer comes from in the first place, including why an unknown key is false, is
the registry and its overrides.
What this page does not cover
Feature gating at the routing layer, with a constraint that removes the route from the table
entirely rather than answering 404 from inside the controller. That version has one clear advantage,
rails routes tells the truth about what the application serves, and one clear cost: the route set
is loaded once at boot, so flipping the flag needs a restart and the whole no-deploy property is
gone.
Nor does it cover what to do with work already in flight. A background job enqueued while the
feature was on will run after it goes off, and nothing in the gate touches that. The job has to
decide for itself whether a flag flipped since it was queued means abandon or proceed, and the
answer is different for a welcome email than for a refund.
A feature flag turned off has to mean something specific at the HTTP layer, and the choice is made once for the whole application or it is made inconsistently forever. There are four candidates: 404, 403, a redirect somewhere friendly, or a page that renders with the feature's parts missing.
What each answer tells the visitor
403 says the route exists and you may not have it. A redirect to the dashboard says the same thing more politely. Rendering an empty page says the feature exists and is broken. 404 says nothing at all, which is the only answer that does not hand out information.
The information matters more than it first seems, because the person typing URLs at a product is usually not its customer. A 403 on
/api/v1/usersconfirms there is a JSON API to come back for. A redirect on/referralconfirms a referral program exists and is currently shut. Neither is a vulnerability, and both are the sort of detail that turns a bored scan into an interested one.The argument on the other side is real: a signed-in customer who bookmarked a page and now gets 404 has no idea what happened, and a support ticket is the result. That is the cost of this choice, and it is paid by the small number of people who used the feature before it was switched off. A product whose flags are flipped weekly should probably answer differently to a signed-in user than to a stranger. This one answers 404 to both, and the simplicity is the point.
Ten lines of concern
Included in
ApplicationController, so every controller in the application can declare its feature on one line:class_methods dois whatActiveSupport::Concerngives you instead of a nestedmodule ClassMethodsand anextendinincluded do. The**optionssplat is not decoration either: it forwards straight into the callback, sogated_by :support, only: :newworks becauseonly:is a callback option and nothing here needs to know that.head :not_foundrather thanraise ActiveRecord::RecordNotFoundis a deliberate difference. The exception would route throughconfig.exceptions_appand render the application's 404 page, which is friendlier and also renders a layout, a navigation bar and whatever else the page carries. The bare head sends the status and an empty body, which is what a route that does not exist would do.The word that does the work
prepend_before_action, notbefore_action. One word, and it decides the answer for every visitor who is not signed in.Rails before_action order is not configuration, it is registration order: callbacks run in the sequence they were declared, and inheritance counts as declaration. A controller like
App::SupportTicketsControllerinherits an authentication callback fromApplicationController, registered when that class was defined, which means it is already in the chain beforegated_byruns. A plainbefore_actionappends, so authentication goes first and the gate never gets a turn for an anonymous request.prepend_before_actionputs the gate at the front.The difference is visible in one request. With the feature on, an anonymous
GETon/app/support_tickets/newanswers302to/session/new, which is correct: the page exists and you need an account. With the feature off, the same request answers404.Swap the one word and run it again, and the disabled case answers
302to/session/newas well. The feature is off, and the application still offers the visitor a sign-in page for it. Everything else about the code is identical, and nothing fails: the flag works, the gate runs, the module is unreachable. It just announces itself on the way.The gate is not the whole feature
Nine controllers in this codebase declare
gated_by, covering referrals, sign-up, the support inbox on both the customer and admin sides, the blog, three AI controllers and the JSON API base controller. Gating the base controller is worth noticing: every versioned API controller inherits from it, so one declaration closes the entire namespace rather than one endpoint at a time.None of that hides a link. The sidebar wraps its entries in
feature?(:key), the pricing table checks thesignupflag before rendering a free plan, and a mailer that belongs to a feature has to check for itself. A flag is enforced in as many places as the feature has surfaces, and the controller gate is only the one that cannot be worked around by typing.The reverse mistake is more common and worse. Hiding the link alone leaves the feature fully functional for anyone with a bookmark, a browser history entry or a link in an old email, which is precisely the population most likely to use it.
Testing the closed door
The closed case is the one nobody looks at, because while it works there is nothing to see. Six request specs here assert it through a shared example, and testing feature flags in Rails goes through what that example pins down and the one line inside it that turns out to pin nothing.
Where the flag's answer comes from in the first place, including why an unknown key is false, is the registry and its overrides.
What this page does not cover
Feature gating at the routing layer, with a constraint that removes the route from the table entirely rather than answering 404 from inside the controller. That version has one clear advantage,
rails routestells the truth about what the application serves, and one clear cost: the route set is loaded once at boot, so flipping the flag needs a restart and the whole no-deploy property is gone.Nor does it cover what to do with work already in flight. A background job enqueued while the feature was on will run after it goes off, and nothing in the gate touches that. The job has to decide for itself whether a flag flipped since it was queued means abandon or proceed, and the answer is different for a welcome email than for a refund.