Elixir Patterns
Write idiomatic Elixir code following community best practices. Covers pattern matching, OTP, Phoenix, and functional programming patterns.
Elixir Patterns
Guidelines for writing idiomatic Elixir and Phoenix applications.
When to Activate
- Writing or editing Elixir code (
.ex,.exsfiles) - Working with Phoenix applications
- Reviewing Elixir code
Pattern Matching
Use pattern matching in function heads
# GOOD - multiple function clauses
def handle_response({:ok, body}), do: process(body)
def handle_response({:error, reason}), do: log_error(reason)
# BAD - case inside function
def handle_response(response) do
case response do
{:ok, body} -> process(body)
{:error, reason} -> log_error(reason)
end
end
Destructure in function arguments
# GOOD
def create_user(%{"email" => email, "name" => name}) do
%User{email: email, name: name}
end
# BAD
def create_user(params) do
email = params["email"]
name = params["name"]
%User{email: email, name: name}
end
Use guards for type checks
def process(value) when is_binary(value), do: String.upcase(value)
def process(value) when is_integer(value), do: value * 2
def process(value) when is_list(value), do: Enum.sum(value)
Pipe Operator
Use pipes for data transformation
# GOOD
result =
data
|> parse()
|> validate()
|> transform()
|> save()
# BAD - nested calls
result = save(transform(validate(parse(data))))
Start pipes with data, not function calls
# GOOD
user
|> Map.get(:email)
|> String.downcase()
# BAD
Map.get(user, :email)
|> String.downcase()
Error Handling
Use tagged tuples
# GOOD - consistent return types
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
# Then pattern match on result
case find_user(id) do
{:ok, user} -> render(conn, "show.html", user: user)
{:error, :not_found} -> send_resp(conn, 404, "Not found")
end
Use with for multiple operations
# GOOD
def create_order(params) do
with {:ok, user} <- find_user(params.user_id),
{:ok, product} <- find_product(params.product_id),
{:ok, order} <- build_order(user, product, params) do
Repo.insert(order)
end
end
# BAD - nested cases
def create_order(params) do
case find_user(params.user_id) do
{:ok, user} ->
case find_product(params.product_id) do
{:ok, product} ->
# ... more nesting
end
end
end
Phoenix
Keep controllers thin
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
def create(conn, params) do
case Orders.create(params, conn.assigns.current_user) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created")
|> redirect(to: ~p"/orders/#{order}")
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
Use contexts for business logic
defmodule MyApp.Orders do
alias MyApp.{Repo, Order}
def create(attrs, user) do
%Order{}
|> Order.changeset(attrs)
|> Ecto.Changeset.put_assoc(:user, user)
|> Repo.insert()
end
def list_for_user(user) do
Order
|> where(user_id: ^user.id)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
end
Use LiveView for interactive UIs
defmodule MyAppWeb.OrderLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, orders: Orders.list_recent())}
end
def handle_event("delete", %{"id" => id}, socket) do
Orders.delete(id)
{:noreply, assign(socket, orders: Orders.list_recent())}
end
end
Ecto
Use changesets for validation
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
timestamps()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
end
Use composable queries
defmodule MyApp.UserQuery do
import Ecto.Query
def active(query \\ User), do: where(query, [u], u.active == true)
def recent(query \\ User), do: order_by(query, [u], desc: u.inserted_at)
def with_role(query \\ User, role), do: where(query, [u], u.role == ^role)
end
# Usage
User
|> UserQuery.active()
|> UserQuery.with_role(:admin)
|> UserQuery.recent()
|> Repo.all()
OTP
Use GenServer for stateful processes
defmodule MyApp.Counter do
use GenServer
# Client API
def start_link(initial \\ 0) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment, do: GenServer.call(__MODULE__, :increment)
def get, do: GenServer.call(__MODULE__, :get)
# Server callbacks
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_call(:increment, _from, count) do
{:reply, count + 1, count + 1}
end
@impl true
def handle_call(:get, _from, count) do
{:reply, count, count}
end
end
Testing
Use ExUnit with descriptive test names
defmodule MyApp.OrdersTest do
use MyApp.DataCase
describe "create/2" do
test "creates order with valid attrs" do
user = insert(:user)
attrs = %{product_id: 1, quantity: 2}
assert {:ok, order} = Orders.create(attrs, user)
assert order.user_id == user.id
end
test "returns error with invalid attrs" do
user = insert(:user)
attrs = %{}
assert {:error, changeset} = Orders.create(attrs, user)
assert errors_on(changeset) == %{product_id: ["can't be blank"]}
end
end
end