OnRotation is a household meal planner. When we ship something early—AI meal ideas, a new import path—we want a handful of people to see it, not everyone with an account.

We do not run a flags SaaS. Auth is already magic-link email, so “who is this?” is on the request. The system is a table, an allowlist, and one check. That is not a flex about avoiding dependencies for its own sake. It is what Elixir and Phoenix are good at: the domain lives in a context, the UI is a LiveView, persistence is Ecto, and the same module serves the API. You can ship a working feature-flag system in an afternoon and it will feel like the rest of the app.

I wrote about the rest of the architecture separately; this is the smaller piece: turning a feature on for a few people without a deploy.

Why not the usual tools

The Elixir default is FunWithFlags: Ecto or Redis, ETS cache, groups, percentages, PubSub. LaunchDarkly (and Unleash, Flagsmith, and the rest) add targeting rules, client SDKs, and an audit trail. All of that is real product. We did not need it.

We have a small user base and a known list of testers. Percentages and anonymous actors are unused. A vendor SDK on web plus Android plus iOS is another integration to keep honest. FunWithFlags would have been the reasonable library choice; we still would have built Admin UI and wired checks to current_scope. For that amount of surface, a row and a function stayed smaller than adopting someone else’s model—and that is the interesting part. In a lot of stacks, “just build it” is how you get an unmaintainable snowflake. In Elixir, a context plus a schema plus a LiveView is the maintainable shape. The custom version looks like every other OnRotation feature.

Admin vs flags

Admin is not a feature flag. A config list of emails is who can open /admin and hit the admin API. That list changes rarely and should survive a bad database row.

Feature flags are for product. They live in Postgres, get edited from Admin (web, Android, or iOS), and answer: does this signed-in user get this experimental UI? Mixing the two would mean either redeploying to add a beta tester, or handing blog-admin to someone who only needed meal ideas.

The model

A flag has a unique name (ai_meal_ideas), an optional description, and allowed emails. There is no on/off switch, no percentage, no household targeting. Empty emails, or a name that is not in the table, means off. If we forget to create the flag, nobody sees the feature.

schema "feature_flags" do
  field :name, :string
  field :description, :string
  field :allowed_emails, :string
  timestamps(type: :utc_datetime)
end

Emails are a comma-separated string. A join table would be cleaner to query; a textarea we edit a few times a month does not need that.

The check is one function: given the caller’s scope (user + household) and a flag name, is this user’s email on the list? Comparison is case-insensitive. Missing user or missing flag is false. We look the row up when we need it. Caching can wait until a check is actually hot.

Where it gates

Call sites hide the UI and refuse the action. LiveView events are still reachable if you know the name, so the same predicate runs before we spend money or mutate data.

AI meal ideas is the live example. The feature also needs an AI backend configured (Gemini, Ollama, or OpenAI). Env says the machine can do it; the flag says which people may. Both have to be true:

defp meal_ideas_enabled?(socket) do
  scope = socket.assigns.current_scope
  MealIdeas.available?() and FeatureFlags.enabled_for_scope?(scope, "ai_meal_ideas")
end

That is the whole gate: a context function and a boolean. The LiveView, the event handlers, and the API can all ask the same question.

Native apps do not evaluate flags for end users yet. They do manage them: the admin API is the same context, not a second source of truth.

What we skipped

We do not do percentage rollouts—we know the emails. We do not evaluate flags on the client and trust the button; the server decides. Names are strings, not an enum, so shipping a feature to everyone is delete the row and grep the checks. Experiments are person-scoped even though meals are household-scoped. If we ever want a household in a beta, that is a new field, not more emails.

Adding a flag is: gate the code, create the row in Admin, paste emails. When it is done, delete the flag and the checks.

Textarea and Postgres is the right amount of machinery. The point is not that flags are easy. It is that Elixir lets a small team build the version that actually fits the product, keep it in the same codebase as the meals and the plan, and stop when it gets the job done.

Try OnRotation at onrotation.app — start with the Starter Pack and plan the week in minutes.