Per-tenant feature flags that do not turn into permanent forks
A client in Noida runs a multi-tenant SaaS for equipment rental yards. Tenant A wants a bulk-import CSV flow. Tenant B wants the same flow but with a two-step approval because their finance team signs off on every upload. Tenant C does not want the import flow at all and is annoyed it appears in their navigation.
The easy fix is a feature flag. The harder problem is that six months later, the flag is still there, the CSV import code has three branches inside it, and nobody remembers which tenant needed the approval step. We have seen this enough times to have a set of rules we follow.
Flags gate access, not behavior
The first rule: a feature flag decides whether code runs for a tenant. It does not decide how the code runs. If Tenant B needs a two-step approval, that is a configuration value on the approval feature, not a flag called `two_step_approval_b_only`.
In practice this means flags are booleans on a tenant-settings table, checked at the route or component level. The import flow itself reads its behavior from tenant configuration:
def can_access_import(tenant):
return tenant.flags.get("csv_import", False)
def import_config(tenant):
return {
"requires_approval": tenant.settings.get("import_requires_approval", False),
"approval_roles": tenant.settings.get("import_approval_roles", ["finance"]),
}The flag controls entry. The config controls behavior. This separation keeps the flag count low and the behavior count explicit.
Every flag has an expiry date
We write the expiry into the flag itself. A flag row has a `created_at`, a `reason`, and an `expected_removal_date`. A scheduled job lists flags past their date in a weekly Slack message. This is not a sophisticated system. It is a text dump. It works because the shame of seeing the same flag in the list three weeks running is enough to make someone schedule the cleanup.
The removal date is not a guess. When we add a flag, we write down what has to be true before we remove it. "Tenant A has used the CSV import for two release cycles without a rollback" is a concrete condition. "It is stable" is not.
One tenant is not a flag
When a single tenant asks for something, we push back on representing it as a flag. A flag implies the feature exists for other tenants who might want it. If only one tenant will ever use it, we build it as a tenant-specific module that is loaded conditionally, not as a branch in shared code.
This is where most flag systems go wrong. A sales-led feature for one customer becomes a flag, then another customer sees it in their admin panel, then support gets a ticket asking why the toggle does nothing for them. If the feature is not on the roadmap for at least two more tenants, it does not get a flag. It gets a module.
The audit we run every quarter
Every quarter we grep the codebase for flag checks. For each flag, we answer three questions: does the flag still exist in the database, does the code path it gates still match the original reason, and has any tenant toggled it in the last 90 days. Flags where the answer to the third question is no get a deprecation plan in the next sprint.
This takes about two hours. We have never regretted doing it. We have regretted skipping it twice.
What we do not do
We do not use a flag system that supports arbitrary percentage rollouts or user-segment targeting. Those features are useful for consumer products with millions of users. For a B2B SaaS with 40 tenants, they add complexity without value. A tenant either has a feature or they do not. Keeping the flag model binary means the flags we do have stay legible.
The goal is not to never have flags. It is to have flags that a new developer can understand in five minutes and remove in one afternoon.