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 --live
    mix phx.new project_name --no-html --no-webpack
    
    # Good
    mix phx.new project-name-web --module ProjectName --app project_name
    mix phx.new project-name-web --module ProjectName --app project_name --live
    mix phx.new project-name-api --module ProjectName --app project_name --no-html --no-webpack
    

    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)
    

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.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
    β”‚Β Β  β”œβ”€β”€ 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
    β”‚Β Β  β”œβ”€β”€ account
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ users.ex # Contains all Accounts User Repo actions (eg: list_all, get_by_username)
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ queries
    β”‚Β Β  β”‚Β Β  β”‚Β   └── user_query.ex # Contains all Accounts User Queries
    β”‚Β Β  β”‚Β Β  └── schemas
    β”‚Β Β  β”‚Β Β  Β Β   └── user.ex # Contains Accounts User Schema and all Accounts User Changesets
    

Web

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

  • When adding β€œnon-conventional” directories in /project_name_web, use the plural for the directory name

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

LiveView

  • Add _live suffix to the folder and the live module

    # less preferred
    β”œβ”€β”€ live
    β”‚   └── verification
    β”‚       └── show.ex
    
    # preferred
    β”œβ”€β”€ live
    β”‚   └── verification_live
    β”‚       └── show_live.ex
    
  • Add _component suffix to LiveView Component for both LiveView Module and LiveView template

  • Prefer to keep all LiveView components inside the components folder.

    # less preferred
    β”œβ”€β”€ live
    β”‚   └── verification_live
    β”‚       └── item.ex
    β”‚       └── show_live.ex
    β”œβ”€β”€ templates
    β”‚   └── verification
    β”‚       └── item.html.leex
    β”‚       └── show.html.html.leex
    └── views
        └── verification_view.ex
    
    # preferred
    β”œβ”€β”€ live
    β”‚   └── verification_live
    β”‚       └── components
    β”‚           └── item_component.ex
    β”‚       └── show_live.ex
    β”œβ”€β”€ templates
    β”‚   └── verification
    β”‚       └── components
    β”‚           └── item_component.html.leex
    β”‚       └── show.html.html.leex
    └── views
        └── verification_view.ex
    
  • Keep all LiveView templates (*.html.leex) under the /templates folder instead of the /live folder, use Phoenix.View.render/3 to render a specific LiveView template.

    # project-name-web/lib/project_name_web/live/verification_live/show_live.ex
    @impl true
    def render(assigns) do
      Phoenix.View.render(VerificationView, "show.html", assigns)
    end
    
    # project-name-web/lib/project_name_web/live/verification_live/components/item_component.ex
    @impl true
    def render(assigns) do
      Phoenix.View.render(VerificationView, "components/item_component.html", assigns)
    end
    

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

Prefer to use all practices mentioned in Web - Database.

  • 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
    

LiveView

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

    <button type="button" phx-click="increase_temperature">
    
    <%= 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