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.mix phx.new project_name mix phx.new project_name --no-live mix phx.new project_name --no-html --no-assets
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
-
Name
Ecto.Multi
operations with meaningful naming that provides context to the function performed.Multi.new() |> Multi.update(:comment, fn _ -> ... end) |> Multi.run(:post, fn _, _ -> ... end)
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) orconfig/runtime.exs
(mix release mode with Elixir ~> 1.11). -
Use
System.fetch_env!
to access environment variables inconfig/prod.secret.exs
orconfig/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
perpipe_through
statement. It will be easier to group and manage routes.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
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, the
User
could be put in theAccount
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 theLiveView
modules.# less preferred βββ live β βββ verification # Context β βββ show.ex # LiveView module
# preferred βββ live β βββ verification # Context β βββ show_live.ex # LiveView module
-
Add the
_component
suffix toLiveComponent
modules. -
Prefer to place all
LiveComponent
inside thecomponents
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 theproject_name
as a prefix and singular for the directory nameβββ lib β βββ project_name_mail β βββ project_name_worker
Database
-
Prefer Pipe-based syntax over Keyword-based for Ecto queries.
from(p in Payment, where: p.status == "pending", select: p.status)
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 therender/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.
gettext("Failed to get user") ngettext("1 product", "%{count} products", length(@products))
dgettext("errors", "Failed to get user") dngettext("product", "1 product", "%{count} products", length(@products))
Plug
-
module plugs
overfunction plugs
. -
Place all module plugs in the directory
project_name_web/plugs
.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
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
-
Prefer to use oban to handle the Background jobs.
Distributed Cluster
- libcluster to form the cluster.Prefer to use
- horde to distribute Supervisor and Registry across nodes.Prefer to use