Phoenix 🦩

Hero image for Phoenix 🦩

All Elixir conventions apply to Phoenix projects. Additionally, when working with the Phoenix framework, a superset of conventions must be followed.

Naming

  • Suffix each project root name with -web or -api depending on the project type.

    # Bad
    mix phx.new project_name
    mix phx.new project_name --no-live
    mix phx.new project_name --no-html --no-assets
    
    # Good
    mix phx.new project-name-web --module ProjectName --app project_name
    mix phx.new project-name-web --module ProjectName --app project_name --no-live
    mix phx.new project-name-api --module ProjectName --app project_name --no-html --no-assets
    

    Use the team template for all Phoenix projects.

  • Name Ecto.Multi operations with meaningful naming that provides context to the function performed.

    # Bad
    Multi.new()
    |> Multi.update(:comment, fn _ -> ... end)
    |> Multi.run(:post, fn _, _ -> ... end)
    
    # Good
    Multi.new()
    |> Multi.update(:update_comment, fn _ -> ... end)
    |> Multi.run(:publish_post, fn _, _ -> ... end)
    
  • Prefer naming the normal HTML template files in snake_case e.g. product_detail.html.eex and use _ prefix for the partial HTML template filenames e.g. _movie.html.eex.

      <%= for movie <- @movies do %>
        <%= render "_movie.html", movie: movie, conn: @conn %>
      <% end %>
    

Dependencies

Dependencies in mix.exs must be sorted in alphabetical order.

defp deps do
  [
    {:argon2_elixir, "~> 2.0"},
    {:credo, "~> 1.5.5", [only: [:dev, :test], runtime: false]},
    {:dialyxir, "~> 1.1.0", [only: [:dev], runtime: false]},
    {:ecto_sql, "~> 3.4"},
    {:ex_machina, "~> 2.7.0", [only: [:dev, :test]]},
    {:excoveralls, "~> 0.14.0", [only: :test]},
    {:faker_elixir_octopus, "~> 1.0.0", only: [:dev, :test]}
    {:gettext, "~> 0.11"},
    {:guardian, "~> 2.0"},
    {:jason, "~> 1.0"},
    {:mimic, "~> 1.4.0", [only: :test]},
    {:oban, "~> 2.5.0"},
    {:phoenix, "~> 1.5.8"},
    {:phoenix_ecto, "~> 4.1"},
    {:phoenix_html, "~> 2.11"},
    {:phoenix_live_dashboard, "~> 0.4"},
    {:phoenix_live_reload, "~> 1.2", only: :dev},
    {:plug_cowboy, "~> 2.0"},
    {:postgrex, ">= 0.0.0"},
    {:sobelow, "~> 0.11.1", [only: [:dev, :test], runtime: false]},
    {:telemetry_metrics, "~> 0.4"},
    {:telemetry_poller, "~> 0.4"},
    {:wallaby, "~> 0.28.0", [only: :test, runtime: false]},
  ]
end

After initializing a new project or when adding a new dependency, the list of dependencies must be arranged manually.

Release

Prefer releasing the application with mix release or distillery instead of mix mode.

Environment Configuration

  • Place all environment variables in config/prod.secret.exs (mix mode) or config/runtime.exs (mix release mode with Elixir ~> 1.11).

  • Use System.fetch_env! to access environment variables in config/prod.secret.exs or config/runtime.exs.

    config :new_relic_agent,
      app_name: System.fetch_env!("NEW_RELIC_APP"),
      license_key: System.fetch_env!("NEW_RELIC_LICENSE")
    

    Since this file is loaded on application boot, it will fail if an environment variable is missing i.e. fast-fail strategy.

Routes

  • Prefer to create a separate scope per pipe_through statement. It will be easier to group and manage routes.

    # Bad
    scope "/v1", AwesomeApp, as: :api_v1 do
      pipe_through :api
    
      get "/health_check", V1.HealthCheckController, :index
    
      pipe_through [:api, :require_authenticated_user]
    
      get "/me", V1.UserController, :show
    end
    
    # Good
    scope "/v1", AwesomeApp, as: :api_v1 do
      scope "/" do
        pipe_through :api
    
        get "/health_check", V1.HealthCheckController, :index
      end
    
      scope "/" do
        pipe_through [:api, :require_authenticated_user]
    
        get "/me", V1.UserController, :show
      end
    end
    

Project Structure

Follow the default structure from the Phoenix guide.

β”œβ”€β”€ _build
β”œβ”€β”€ assets
β”œβ”€β”€ config
β”œβ”€β”€ deps
β”œβ”€β”€ lib
β”‚   └── project_name
β”‚   └── project_name_web
β”‚   └── project_name_worker
β”‚   └── project_name.ex
β”‚   └── project_name_web.ex
β”œβ”€β”€ priv
└── test

Business Domain

  • The /project_name directory hosts all business domains.

  • Use Context to decouple and isolate the systems into manageable and independent parts.

  • Prefer to use contexts every time, even if that context has a single model.

  • Prefer to keep the context name singular.

    For example, we could put the User in the Account context

    β”œβ”€β”€ project_name
    β”‚Β Β  β”œβ”€β”€ account
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ users.ex # Contains all Account User Repo actions (eg: list_all, get_by_username)
    β”‚Β Β  β”‚Β Β  └── helpers
    β”‚Β Β  β”‚Β Β  Β Β   └── account_helper.ex # Contains Account helpers.
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ queries
    β”‚Β Β  β”‚Β Β  β”‚Β   └── user_query.ex # Contains all Accounts User Queries
    β”‚Β Β  β”‚Β Β  └── schemas
    β”‚Β Β  β”‚Β Β  Β Β   └── user.ex # Contains Account User Schema and all Accounts User Changesets
    β”‚Β Β  β”œβ”€β”€ exception # Contains all custom exceptions (eg: PlayerNotFoundException, InvalidTokenException)
    β”‚Β Β  β”‚Β Β  Β  Β  β”œβ”€β”€ invalid_token_exception.ex
    β”‚Β Β  β”‚Β Β  Β  Β  └── player_not_found_exception.ex
    β”‚Β Β  β”œβ”€β”€ game
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ games.ex # Contains all Game Context actions (eg: save_game_state, load_game_state)
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ heroes.ex # Contains all Game Hero Repo actions (eg: list_all, get_by_name)
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ items.ex # Contains all Game Item Repo actions (eg: list_all, get_by_slug)
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ queries
    β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ hero_query.ex # Contains all Game Hero Queries
    β”‚Β Β  β”‚Β Β  β”‚Β Β  └── item_query.ex # Contains all Game Item Queries
    β”‚Β Β  β”‚Β Β  └── schemas
    β”‚Β Β  β”‚Β Β      β”œβ”€β”€ hero.ex # Contains Game Hero Schema and all Game Hero Changesets
    β”‚Β Β  β”‚Β Β      └── item.ex # Contains Game Item Schema and all Game Item Changesets
    

Web

  • The /project_name_web directory hosts all web-related parts.

  • Group all helpers for the web application in the /helpers directory since some helpers can be shared among controllers, views, or templates.

    β”œβ”€β”€ awesome_web
    β”‚   β”œβ”€β”€ helpers
    β”‚   β”‚  β”œβ”€β”€ error_helper.ex
    β”‚   β”‚  β”œβ”€β”€ input_helper.ex
    β”‚   β”‚  └── layout_helper.ex
    
  • When adding β€œnon-conventional” directories in /project_name_web, use the plural for the directory name

    β”œβ”€β”€ project_name_web
    β”‚   └── params
    β”‚   └── plugs
    

LiveView

  • Add the _live suffix to the LiveView modules.

    # less preferred
    β”œβ”€β”€ live
    β”‚   └── verification  # Context
    β”‚       └── show.ex   # LiveView module
    
    # preferred
    β”œβ”€β”€ live
    β”‚   └── verification      # Context
    β”‚       └── show_live.ex  # LiveView module
    
  • Add the _component suffix to LiveComponent modules.

  • Prefer to place all LiveComponent inside the components directory.

      β”œβ”€β”€ live
      |   └── articles  # Context
      |       β”œβ”€β”€ components
      β”‚       β”‚   β”œβ”€β”€ table_component.ex        # LiveComponent module
      β”‚       β”‚   └── table_component.html.heex # LiveComponent template
    
  • Place all templates for LiveView/LiveComponent (*.html.heex) under the /live directory alongside their live module, and use the same name as the live module.

    β”œβ”€β”€ live
    β”‚   └── verification  # Context
    |       β”œβ”€β”€ components
    β”‚       β”‚   β”œβ”€β”€ item_component.ex         # LiveComponent module
    β”‚       β”‚   └── item_component.html.heex  # LiveComponent template
    β”‚       β”œβ”€β”€ show_live.ex      # LiveView module
    β”‚       └── show.html.heex    # LiveView template
    

Non-conventional Directories

  • When adding β€œnon-conventional” directories in /lib, use the project_name as a prefix and singular for the directory name

    β”œβ”€β”€ lib
    β”‚   └── project_name_mail
    β”‚   └── project_name_worker
    

Database

All practices on the Web - Database page apply to Phoenix projects. The following section brings practices and recommendations specific to Phoenix projects.

  • Prefer Pipe-based syntax over Keyword-based for Ecto queries.

    # Least preferred
    from(p in Payment, where: p.status == "pending", select: p.status)
    
    # Preferred
    Payment
    |> where([p], p.status == "pending")
    |> select([:status])
    

Database Migration

  • Prefer to use multiple migration directories to separate long-running migrate tasks.

    β”œβ”€β”€ priv
    β”‚Β Β  └── repo
    β”‚Β Β      β”œβ”€β”€ migrations # run automatically
    β”‚Β Β      β”œβ”€β”€ manual_migrations # run manually
    
  • Avoid calling application modules or functions within migration files.

    defmodule AwesomeApp.Repo.Migrations.AddLocaleToUsers do
      use Ecto.Migration
    
      # Bad
      alias AwesomeApp.Users
    
      def change do
        alter table(:users) do
          add(:locale, :string)
        end
    
        # Bad
        Users.update_users_locale()
      end
    end
    
  • For the data migration, prefer to use SQL commands or release tasks depending on the data complexity.

    Using SQL commands is the preferred option if the migrating data are straightforward, such as fixed value or current datetime.

    defmodule AwesomeApp.Repo.Migrations.AddLocaleToUsers do
      use Ecto.Migration
    
      def change do
        alter table(:users) do
          add(:locale, :string)
        end
    
        execute("UPDATE Users SET locale='EN'", "")
      end
    end
    

    If the migrating data are complex or require business logic, prefer creating release tasks and running them once the changes are deployed.

    defmodule AwesomeApp.Repo.Migrations.AddLocaleToUsers do
      use Ecto.Migration
    
      def change do
        alter table(:users) do
          add(:locale, :string)
        end
      end
    end
    
    defmodule AwesomeApp.ReleaseTasks do
      def update_users_locale() do
        # Iterating through users and determining their locale depends on the business logic.
      end
    end
    
  • Always define down operation SQL commands for the execute/2 to make the migration reversible.

    defmodule AwesomeApp.Repo.Migrations.MyMigration do
      use Ecto.Migration
    
      def change do
        execute execute_up(), execute_down()
      end
    
      defp execute_up, do: "CREATE EXTENSION IF NOT EXISTS citext"
      defp execute_down, do: "DROP EXTENSION IF EXISTS citext"
    end
    

LiveView

  • For live modules (LiveView/LiveComponent), prefer to separate the template to its HEEx template file by omitting the render/1 function inside the live module and placing the template file alongside the module.

    β”œβ”€ live/
    β”‚  β”œβ”€ landings/
    |  |  β”œβ”€ components/
    β”‚  β”‚  β”‚  β”œβ”€ greeting_component.ex          # LiveComponent module
    β”‚  β”‚  β”‚  β”œβ”€ greeting_component.html.heex   # LiveComponent's template
    |  |  β”œβ”€ show_live.ex     # LiveView module
    |  |  β”œβ”€ show.html.heex   # LiveView's template
    

Event Handlers

  • Prefer using a string for an event name in the handle_event/3 callback as it works on both HTML tag and HEEx template.

    <button type="button" phx-click="increase_temperature"></button>
    
    <%= content_tag :button, type: "button", phx_click: "increase_temperature" %>
    
    def handle_event("increase_temperature", _value, socket) do
      new_temperature = socket.assigns.temperature + 1
    
      {:noreply, assign(socket, :temperature, new_temperature)}
    end
    
  • Prefer using an atom when sending and handling a message in the handle_info/2 callback as it is an internal message and rarely changes.

      # Message without payload
      def handle_info(:increase_temperature, socket) do
        new_temperature = socket.assigns.temperature + 1
    
        {:noreply, assign(socket, :temperature, new_temperature)}
      end
    
      # Message with payload
      def handle_info({:increase_temperature, value}, socket) do
        new_temperature = socket.assigns.temperature + value
    
      {:noreply, assign(socket, :temperature, new_temperature)}
    end
    

Gettext

  • Prefer to use a specific Domain for translation.

    # Bad
    gettext("Failed to get user")
    ngettext("1 product", "%{count} products", length(@products))
    
    # Good
    dgettext("errors", "Failed to get user")
    dngettext("product", "1 product", "%{count} products", length(@products))
    

Plug

  • Prefer module plugs over function plugs.
  • Place all module plugs in the directory project_name_web/plugs.

    # Bad
    defmodule ProjectNameWeb.SampleController do
      use ProjectNameWeb, :controller
    
      plug :ensure_item_exists when action in [:new]
    
      def new(conn, _) do
        # ...
      end
    
      defp ensure_item_exists(conn, _options) do
        # ...
      end
    end
    
    # Good
    defmodule ProjectNameWeb.EnsureItemExistsPlug do
      import Plug.Conn
    
      @impl true
      def init(opts), do: opts
    
      @impl true
      def call(conn, _opts) do
        # ...
      end
    end
    
    defmodule ProjectNameWeb.SampleController do
      use ProjectNameWeb, :controller
    
      plug EnsureItemExistsPlug when action in [:new]
    
      def new(conn, _) do
        # ...
      end
    end
    

Worker

Distributed Cluster

  • Prefer to use libcluster to form the cluster.
  • Prefer to use horde to distribute Supervisor and Registry across nodes.