Published on

Development helpers in Elixir

How do you efficiently set up development-only logic in Elixir? In one directory: _dev.

This directory will contain all your development-only logic. Combined with config/dev.exs configuration, this makes a clear and safe separation from your production logic. There will be no need for awkward Mix.env() == :dev blocks around inline code (apart from two Phoenix files). The following example will be based on a Phoenix project, but you can use this pattern for any Elixir project.

Let us get started!

mix.exs and .formatter.exs

First, we want to include _dev files in our dev and test environments by updating elixirc_paths in mix.exs:

defmodule MyApp.MixProject do
  use Mix.Project

  # ...

  # Specifies which paths to compile per environment.
  # We include `_dev` directory in the test/dev env to
  # enable the dev tools
  defp elixirc_paths(:test), do: ["lib", "test/support", "_dev", "test/_dev/support"]
  defp elixirc_paths(:dev), do: ["lib", "_dev"]
  defp elixirc_paths(_), do: ["lib"]

  # ...

We also want mix format to pick up on it, so update the .formatter.exs:

  import_deps: [:phoenix],
  plugins: [Phoenix.LiveView.HTMLFormatter],
  inputs: ["*.{heex,ex,exs}", "{_dev,config,lib,test}/**/*.{heex,ex,exs}"]

That is it. Now you can put all your dev-only files in the _dev directory and it will only ever get included in the dev and test environments. We can test our dev-only logic and add dev-only test support files to test/_dev/support.

Example: Seed tool

Let us set up a development helper to manually seed a variety of scenarios in our environment.

First, we may want to add some helpers in _dev/my_app, for example _dev/my_app/seed_helper.exs:

defmodule MyApp.Dev.SeedHelper do
  # ...

I’m namespacing the module names with Dev. Though it’s just a dev-only tool we still need to test it, so we’ll create the test file in test/_dev/my_app/seed_helper_test.exs:

defmodule MyApp.Dev.SeedHelperTest do
  use MyApp.DataCase

  # ...

For Phoenix, we’ll have to update our router to enable our dev-only routes. This along with navigation in the UI will be the only parts that can’t be completely separated from anything that touches production code.

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  # ...

  # Enable dev tools for dev and test env
  if Application.compile_env(:my_app_web, :dev_tools) do
    scope "/dev" do
      pipe_through :browser

      scope "/tools", MyAppWeb.Dev.Tools do
        scope "/seeds" do
          live "/", SeedLive, :index
          live "/:id/run", SeedLive, :run

In this case, I set a config to enable the dev tools in config/config.exs as:

config :my_app_web, :dev_tools, Mix.env() in [:dev, :test]

You could also just use Mix.env() straight in the router, but I found the config pattern more comfortable.

We should add a link in our template as well:

<%= if Application.get_env(:my_app_web, :dev_tools) do %>
  <.link navigate={"/dev/tools/seed"}>Seed</.link>
<% end %>

Now we’ll set up our dev tools, it could be in _dev/my_app_web/tools/live/seed_live.ex:

defmodule MyAppWeb.Dev.Tools.SeedLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :seeds, list_seed_files())}

  # ...

A test for the above should be in test/_dev/my_app_web/tools/seed_live_test.exs.

defmodule MyAppWeb.Dev.Tools.SeedLiveTest do
  use MyAppWeb.ConnCase

  test "lists and run seeds", %{conn: conn} do
    # ...

This has worked very well for my team, and I highly recommend this pattern.

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process