Feature flags for Elixir with deterministic per-entity rollouts.
Why this exists
Botica already deals with runtime diagnostics and configuration. Feature flags are a natural extension: they let you toggle behaviour at runtime without redeploying, and they let you roll features out to a percentage of users safely.
The implementation is intentionally tiny:
- Storage: ETS (
:set,:public,read_concurrency: true). No external service, no Redis, no Postgres. - Writes: Serialised through
Botica.Flags.Store(GenServer) to avoid race conditions. - Reads: O(1) direct ETS lookup via the Store — no GenServer round-trip on the hot path.
- Rollout:
:erlang.phash2/2for deterministic bucketing —enabled?(name, for: user_id)is stable across restarts.
Quick start
# 1. Define flags (typically at boot, e.g. in your Application.start)
Botica.Flags.define(:new_dashboard, default: false)
Botica.Flags.define(:beta_search, default: true)
Botica.Flags.define(:rate_limiting, default: false, rollout: 25)
# 2. Query at runtime
Botica.Flags.enabled?(:new_dashboard) # => false
Botica.Flags.enabled?(:beta_search) # => true
Botica.Flags.enabled?(:rate_limiting, for: user_id) # => true / false deterministically
# 3. Mutate at runtime (serialised via GenServer)
Botica.Flags.enable(:new_dashboard)
Botica.Flags.disable(:new_dashboard)
Botica.Flags.set(:rate_limiting, rollout: 50)
# 4. Introspection
Botica.Flags.all() # => [%Flag{}, ...]
Botica.Flags.get(:foo) # => {:ok, %Flag{}} | :error
Botica.Flags.count() # => 3Rollout semantics
When rollout: N is set (0 ≤ N ≤ 100) AND enabled: true, the flag is
on for the first N percent of entities, where "entity" is any term you
pass as for: (typically a user_id or session_id). The bucketing is
deterministic:
iex> Botica.Flags.define(:r, default: false, rollout: 50)
iex> Botica.Flags.enable(:r)
iex> Botica.Flags.enabled?(:r, for: "user_42")
true
iex> Botica.Flags.enabled?(:r, for: "user_42") # always the same
trueWhen rollout: nil (the default), the flag is binary on/off and the
for: option is ignored.
Summary
Functions
Returns all registered flags (most recently updated first).
Total number of registered flags.
Defines a new flag or updates an existing one.
Removes a flag from the registry.
Forces a flag to disabled. Existing rollout percentages are preserved.
Forces a flag to enabled regardless of its rollout percentage.
Returns whether a flag is enabled. Two-arity version takes a deterministic
for: argument used for rollout bucketing.
Returns the flag struct or :error.
Updates one or more attributes of an existing flag.
Functions
@spec all() :: [Botica.Flags.Flag.t()]
Returns all registered flags (most recently updated first).
@spec count() :: non_neg_integer()
Total number of registered flags.
Defines a new flag or updates an existing one.
Options
:default— value when flag is not enabled (defaultfalse):description— optional human-readable note:rollout—0..100percentage when enabled (defaultnil)
Returns :ok always (write is sync via GenServer.call).
Examples
Botica.Flags.define(:new_dashboard, default: false)
Botica.Flags.define(:beta_search, default: true, description: "WIP")
Botica.Flags.define(:rate_limiting, default: false, rollout: 25)
@spec delete(atom()) :: :ok
Removes a flag from the registry.
@spec disable(atom()) :: :ok
Forces a flag to disabled. Existing rollout percentages are preserved.
@spec enable(atom()) :: :ok
Forces a flag to enabled regardless of its rollout percentage.
The flag is also created with default = true if it didn't exist.
Returns whether a flag is enabled. Two-arity version takes a deterministic
for: argument used for rollout bucketing.
Behaviour:
- If the flag is not defined → returns its default value (or
false). - If the flag is defined and
enabled: false→ returnsfalse. - If the flag is defined,
enabled: true, androllout: nil→ returnstrue. - If the flag is defined,
enabled: true, androllout: P→ returnstruefor the first P% of entities (deterministic hash).
Examples
iex> Botica.Flags.define(:a, default: false)
iex> Botica.Flags.enabled?(:a)
false
iex> Botica.Flags.define(:b, default: true)
iex> Botica.Flags.enabled?(:b)
true
@spec get(atom()) :: {:ok, Botica.Flags.Flag.t()} | :error
Returns the flag struct or :error.
Updates one or more attributes of an existing flag.
Accepted keys: :enabled, :rollout, :description, :default.
Examples
Botica.Flags.set(:rate_limiting, rollout: 50)
Botica.Flags.set(:new_dashboard, enabled: true, description: "Rolled out to all")