Migrating from fun_with_flags to Bandera

Copy Markdown View Source

Bandera keeps the same public API and gate model you already use, so migration is mostly a find-replace plus a config move. The one structural difference is that all configuration is read at runtime. There is no compile_env, so config can live in config/runtime.exs and change without a recompile.

This guide walks through the change end to end. Most apps only need steps 1-3.

At a glance

fun_with_flagsBandera
{:fun_with_flags, "~> x.y"}{:bandera, "~> 0.4.0"}
FunWithFlags.enabled?/2, enable/2, disable/2, clear/2identical on Bandera
FunWithFlags.Actor / FunWithFlags.GroupBandera.Actor / Bandera.Group
config :fun_with_flags, :cache, ...config :bandera, cache: [...]
FunWithFlags.Store.Persistent.EctoBandera.Store.Persistent.Ecto
FunWithFlags.Store.Persistent.RedisBandera.Store.Persistent.Redis
FunWithFlags.Notifications.RedisBandera.Notifications.Redis
FunWithFlags.Notifications.PhoenixPubSubBandera.Notifications.PhoenixPubSub

Step 1: Swap the dependency

In mix.exs, replace the dependency:

# before
{:fun_with_flags, "~> 1.0"},

# after
{:bandera, "~> 0.4.0"},

Keep whichever backend deps you already had (ecto_sql, redix, phoenix_pubsub). They are optional in Bandera too; add only what you use. If you adopt the test layer (step 7), also add:

{:nimble_ownership, "~> 1.0", only: :test}

Then run mix deps.get.

Step 2: Rename the API calls

The function names and signatures are unchanged; only the module changes. Find-replace FunWithFlagsBandera across your code:

# before
FunWithFlags.enable(:checkout)
FunWithFlags.enabled?(:beta, for: current_user)

# after
Bandera.enable(:checkout)
Bandera.enabled?(:beta, for: current_user)

All of these carry over with identical behavior: enabled?/2, enable/2, disable/2, clear/2, get_flag/1, all_flags/0, all_flag_names/0. The gate options (for_actor:, for_group:, for_percentage_of: {:time | :actors, ratio}) are the same.

Step 3: Move the configuration

Change the config key from :fun_with_flags to :bandera. Bandera reads each setting as a keyword under the app, and reads it at runtime, so you can keep it in config/config.exs or move it to config/runtime.exs.

# before (fun_with_flags)
config :fun_with_flags, :cache,
  enabled: true,
  ttl: 900

config :fun_with_flags, :persistence,
  adapter: FunWithFlags.Store.Persistent.Ecto,
  repo: MyApp.Repo

config :fun_with_flags, :cache_bust_notifications,
  enabled: true,
  adapter: FunWithFlags.Notifications.PhoenixPubSub,
  client: MyApp.PubSub
# after (Bandera)
config :bandera,
  cache: [enabled: true, ttl: 900],
  persistence: [
    adapter: Bandera.Store.Persistent.Ecto,
    repo: MyApp.Repo
  ],
  cache_bust_notifications: [
    enabled: true,
    adapter: Bandera.Notifications.PhoenixPubSub,
    client: MyApp.PubSub
  ]

Defaults if you omit a section: in-memory store, cache on (900s TTL), notifications off. Call Bandera.reload_config/0 to re-read config at runtime.

Note on Redis connection options. fun_with_flags uses one shared config :fun_with_flags, :redis, [...]. Bandera nests the Redix options under whichever component uses them:

config :bandera,
  persistence: [adapter: Bandera.Store.Persistent.Redis, redis: [host: "localhost", port: 6379]],
  cache_bust_notifications: [enabled: true, adapter: Bandera.Notifications.Redis, redis: [host: "localhost", port: 6379]]

Step 4: Re-implement the protocols

If you defined FunWithFlags.Actor / FunWithFlags.Group for your structs, re-implement them under the Bandera namespace. The callbacks are the same: id/1 for actors, in?/2 for groups:

# before
defimpl FunWithFlags.Actor, for: MyApp.User do
  def id(user), do: "user:#{user.id}"
end

defimpl FunWithFlags.Group, for: MyApp.User do
  def in?(user, group_name), do: group_name in user.roles
end

# after
defimpl Bandera.Actor, for: MyApp.User do
  def id(user), do: "user:#{user.id}"
end

defimpl Bandera.Group, for: MyApp.User do
  def in?(user, group_name), do: group_name in user.roles
end

Bandera ships built-in implementations for binaries, integers, and maps with an :id / :groups key, just like fun_with_flags.

Step 5: Persistence data

The Ecto table schema is the same shape as fun_with_flags (flag_name, gate_type, target, enabled), so you have two options:

Option A: keep your existing table. Point Bandera at it by name and skip any data migration:

config :bandera,
  persistence: [
    adapter: Bandera.Store.Persistent.Ecto,
    repo: MyApp.Repo,
    ecto_table_name: "fun_with_flags_toggles"
  ]

⚠ Boolean gate compatibility note. FunWithFlags stored boolean gates with a different internal sentinel than Bandera uses. If any flags had their boolean state set while FunWithFlags was active, your table may have duplicate boolean gate rows that cause the toggle to appear stuck. Run the one-time cleanup migration after switching:

mix bandera.gen.fix_fun_with_flags_migration
mix ecto.migrate

This generates and runs a migration that normalises any legacy rows. It is safe to run on a table that has already been fully migrated — it finds nothing to change.

Option B: create a fresh Bandera table and copy your rows over. Generate the table from a migration:

defmodule MyApp.Repo.Migrations.CreateBanderaFlags do
  use Ecto.Migration

  def up, do: Bandera.Ecto.Migrations.up()
  def down, do: Bandera.Ecto.Migrations.down()
end

The table name defaults to "bandera_flags" and is read from runtime config.

For Redis persistence, Bandera uses its own key prefix (bandera:flag:<name> hashes plus a bandera:flag_names set). Either re-create flags via the API after cutover, or migrate keys to the new prefix.

Step 6: Notifications

Swap the notification adapter module names; the behavior (cross-node cache-busting) is unchanged:

The Phoenix.PubSub adapter still takes your PubSub server via client:.

Step 7: Tests (the setup is different)

This is the one area where the migration is more than a rename. With fun_with_flags, flag state is global (shared ETS/DB), so toggling a flag in one test is visible to every other test. The usual workarounds are running flag tests with async: false and clearing flags manually (e.g. FunWithFlags.clear/1 in setup/on_exit). Tests that write flags can also deadlock under the Ecto SQL sandbox.

Bandera replaces that model entirely with an async-safe, process-scoped test layer. Overrides are scoped to the test process (and its spawned tasks), so async: true tests don't bleed into each other, toggling a flag never touches the database, and cleanup is automatic when the test process exits, with no setup or on_exit plumbing.

Because the model is different, you need to opt into it explicitly (this has no fun_with_flags equivalent):

# config/test.exs: select the process-scoped store
config :bandera, store: Bandera.Store.ProcessScoped

# test/test_helper.exs: start the override server once
Bandera.Test.start()

Add the test-layer dependency to mix.exs (see step 1):

{:nimble_ownership, "~> 1.0", only: :test}

Then drop the async: false you needed for flag tests and use Bandera.Test:

defmodule MyApp.CheckoutTest do
  use ExUnit.Case, async: true
  use Bandera.Test

  @tag feature_flags: [checkout: true]
  test "feature on via tag" do
    assert Bandera.enabled?(:checkout)
  end

  test "toggle in the body" do
    enable_flag(:beta)
    assert Bandera.enabled?(:beta)
  end
end

Existing tests that call Bandera.enable/2, Bandera.disable/2, etc. keep working unchanged: when Bandera.Store.ProcessScoped is configured, those calls are transparently redirected to the process-scoped overrides instead of the shared store.

Done

Steps 1-4 cover most apps; add 5-7 as needed. Same behavior as before, with config now resolved at runtime.