Exploration spike following expert-panel synthesis. Codifies the 3-tier gating model (instance / per-user / per-family) without adding a framework. Investments is the first family-scoped module. - Family#disabled_modules: string[] column, opt-out semantics. No per-module DB column. Existing families default to [] = enabled. - Family::AVAILABLE_MODULES = %w[investments] (the registry). - ModuleGateable concern (auto-included in ApplicationController): require_module! class macro + module_enabled? helper. - Api::V1::BaseController#require_module!: 403 feature_disabled JSON, mirrors require_ai_enabled. - NavigationHelper extracts mobile/desktop nav into a single source with module: key support; mobile shrinks via justify-around, never auto-fills empty slots. - Settings → Preferences gains a family-scoped module toggle card. - 4 HTML controllers (Investments, Holdings, Trades, Securities) + 3 API controllers gated. Investment/Crypto account types hidden in the new-account modal when off. - docs/feature-gating.md codifies the rule for future modules. Background-job layer not wired (no Investments-specific scheduled job to gate; flagged as TODO in docs). Run db:migrate before bin/rails test. No PR yet — awaiting decisions in open-questions list.
5.5 KiB
Feature Gating
Sure already has three layered mechanisms for turning features on and off. Use them. Do not introduce a new framework (no Flipper, no plugin loader, no app/modules/*/ autoload tree).
The three gating tiers
| Tier | Where | When to use | Precedent |
|---|---|---|---|
| Instance | Rails.application.config.app_mode (config/application.rb) + env vars like SIMPLEFIN_INCLUDE_PENDING |
Whole-deploy switches: managed vs self-hosted, opt-in integrations that require credentials. | app_mode.self_hosted?, Rails.configuration.x.simplefin.* |
| Per-user | User#preferences jsonb |
Personal preferences that affect only that user's UI. | User#preview_features_enabled? (app/models/user.rb), User#ai_enabled? |
| Per-family ("modules") | Family#disabled_modules text[] + Family#module_enabled?(:name) |
Toggling whole feature verticals (Investments, future: Budgets, Goals, AI). Affects everyone in the family. | Family#recurring_transactions_disabled? (existing), Family#module_enabled?(:investments) (this doc) |
The "module" rule
A module is just a string in families.disabled_modules. It opts a feature vertical OUT — default is enabled, presence in the array means disabled. Backwards-compatible: existing families ship with [] and see no change.
family.module_enabled?(:investments) # => true (default)
family.update!(disabled_modules: ["investments"])
family.module_enabled?(:investments) # => false
No migrations per module. Add an entry to Family::AVAILABLE_MODULES and wire the three enforcement layers below.
Three enforcement layers — required for every module
The biggest risk with feature gating is half-enforcement: hiding the UI but leaving controllers, jobs, or model writes open. A user disables the module, the surface disappears, background jobs keep writing data, and re-enabling produces surprise state. For every module, all three layers must call Family#module_enabled?.
1. View / nav
Inside ERB and helpers, call module_enabled?(:investments). The method is provided as a helper_method by ModuleGateable (auto-included in ApplicationController).
<% if module_enabled?(:investments) %>
<%= render "account_type", accountable: Investment.new %>
<%= render "account_type", accountable: Crypto.new %>
<% end %>
For nav items, opt in via the module: key in NavigationHelper#main_nav_items; disabled modules are filtered out and the bottom-mobile nav redistributes via justify-around. Never auto-fill empty slots with promoted sidebar items — empty slot is the affordance that the module is off.
2. Controller
HTML controllers use the require_module! class macro from ModuleGateable:
class InvestmentsController < ApplicationController
require_module! :investments
end
This redirects to root_path with flash[:alert] = t("modules.not_enabled") when the module is off.
API controllers (under Api::V1) use the instance method on Api::V1::BaseController:
class Api::V1::HoldingsController < Api::V1::BaseController
before_action -> { require_module!(:investments) }
end
This renders { error: "feature_disabled" } with status 403. Matches the existing require_ai_enabled pattern.
3. Background jobs / model writes
If the module has scheduled jobs or model callbacks that write data, guard the entry point:
class SomeInvestmentJob < ApplicationJob
def perform(family_id)
family = Family.find(family_id)
return unless family.module_enabled?(:investments)
# ...
end
end
Without this, toggling off creates silent data accumulation, and toggling back on produces a populated module the user never approved.
Choosing the right tier
- Instance: needs a deploy/restart. Right for integrations bound to env keys (Plaid, SimpleFIN, OpenAI), and for self-hosters who want a feature off at the docker-compose layer.
- Per-user: right for opt-in/opt-out personal preferences that don't affect family-shared data (AI sidebar visibility, preview features, dark mode).
- Per-family module: right for "we don't want this vertical at all" — affects all family members, the toggle lives in Settings → Preferences, and surfaces across web + API + jobs.
If you find yourself wanting all three for one feature, you probably want per-family with a Rails.configuration instance kill-switch. Don't build "the module system" — there isn't one.
Naming and conventions
- Module names are snake_case strings:
"investments","goals". Plural for verticals, singular for single features. - Add to
Family::AVAILABLE_MODULESso the Settings UI picks them up. - Add locale entries under
modules.<name>.titleandmodules.<name>.descriptioninconfig/locales/views/modules/<locale>.yml. - Same string is used in
disabled_modulesarray, controllerrequire_module!arg, viewmodule_enabled?arg, and locale key.
Not modules
These are foundational primitives. They are not candidates for module gating because too much else FKs into them:
- Account, Transaction, Entry, Trade, Valuation, Balance, Holding, Security
- Category, Merchant, Transfer
If you think one of these should be a module, you are wrong; redesign the feature instead.
What about a "modules registry"?
Don't build one. Two reasons:
- You don't have three modules yet, and premature abstraction here is fatal because the surface is forever (per Discourse/WordPress prior art).
Family::AVAILABLE_MODULESis the registry. It's an array. That's the framework.