Best Practices

Master Elixir idioms, naming conventions, design patterns, and learn to write idiomatic, maintainable code.

Naming Conventions

Modules

# Use PascalCase
defmodule MyApp.Accounts.User do
end

# Namespace with top-level app name
MyApp.Blog.Post
MyApp.Web.Router

# Acronyms are treated as words
defmodule MyApp.JSONParser do  # Good
defmodule MyApp.JsonParser do  # Also acceptable

# Avoid
defmodule myapp_user do  # Bad

Functions and Variables

# Use snake_case
def calculate_total(items) do
  total_price = sum_prices(items)
  total_price
end

# Predicates end with ?
def active?(user), do: user.active
def empty?(list), do: list == []

# Dangerous functions end with !
def save!(data)  # Raises on error
def save(data)   # Returns {:ok, result} | {:error, reason}

Private Functions

defmodule Calculator do
  # Public API
  def calculate(a, b) do
    result = do_calculate(a, b)
    format_result(result)
  end

  # Private helpers
  defp do_calculate(a, b), do: a + b
  defp format_result(result), do: "Result: #{result}"
end

Code Organization

Context Pattern

Group related functionality:

# DON'T: God module
defmodule MyApp do
  def create_user, do: ...
  def get_user, do: ...
  def create_post, do: ...
  def send_email, do: ...
end

# DO: Separate contexts
defmodule MyApp.Accounts do
  def create_user, do: ...
  def get_user, do: ...
end

defmodule MyApp.Blog do
  def create_post, do: ...
  def get_post, do: ...
end

defmodule MyApp.Notifications do
  def send_email, do: ...
end

Module Structure

defmodule MyApp.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  # Schema definition
  schema "posts" do
    field :title, :string
    timestamps()
  end

  # Public API
  def changeset(post, attrs) do
    # ...
  end

  # Private functions
  defp validate_title(changeset) do
    # ...
  end
end

Pattern Matching

Use Pattern Matching

# DON'T
def greet(user) do
  if user.name != nil do
    "Hello, #{user.name}"
  else
    "Hello, Guest"
  end
end

# DO
def greet(%{name: name}) when is_binary(name), do: "Hello, #{name}"
def greet(_), do: "Hello, Guest"

Match Early

# DON'T
def process(data) do
  if data.type == :valid do
    # Long processing logic
  else
    {:error, :invalid}
  end
end

# DO
def process(%{type: :valid} = data) do
  # Long processing logic
end

def process(_data) do
  {:error, :invalid}
end

Destructure in Function Heads

# DON'T
def format_user(user) do
  name = user.name
  age = user.age
  "#{name} (#{age})"
end

# DO
def format_user(%{name: name, age: age}) do
  "#{name} (#{age})"
end

Error Handling

Tagged Tuples

# Public API returns tagged tuples
def create_user(attrs) do
  case validate(attrs) do
    :ok -> insert_user(attrs)
    :error -> {:error, :invalid_data}
  end
end

# Let internal functions crash
defp insert_user(attrs) do
  # Will raise if fails - that's ok
  user = Repo.insert!(%User{name: attrs.name})
  {:ok, user}
end

Use with for Pipelines

# DON'T
def process(data) do
  case step1(data) do
    {:ok, result1} ->
      case step2(result1) do
        {:ok, result2} ->
          case step3(result2) do
            {:ok, result3} -> {:ok, result3}
            error -> error
          end
        error -> error
      end
    error -> error
  end
end

# DO
def process(data) do
  with {:ok, result1} <- step1(data),
       {:ok, result2} <- step2(result1),
       {:ok, result3} <- step3(result2) do
    {:ok, result3}
  end
end

Let It Crash

# DON'T: Defensive programming
def divide(a, b) do
  try do
    a / b
  rescue
    _ -> {:error, :failed}
  end
end

# DO: Let it crash, supervisor will restart
def divide(a, b) when b != 0, do: a / b
def divide(_, 0), do: {:error, :division_by_zero}

Pipe Operator

Use Pipes for Transformation

# DON'T
def process(data) do
  result1 = String.trim(data)
  result2 = String.downcase(result1)
  result3 = String.split(result2)
  Enum.join(result3, "-")
end

# DO
def process(data) do
  data
  |> String.trim()
  |> String.downcase()
  |> String.split()
  |> Enum.join("-")
end

One Operation Per Line

# DON'T
data |> transform() |> validate() |> save()

# DO
data
|> transform()
|> validate()
|> save()

Don't Overuse

# DON'T
name = user |> Map.get(:name)

# DO
name = user.name
# or
name = Map.get(user, :name)

Functions

Small Functions

# DON'T: Large function doing multiple things
def create_user(attrs) do
  # Validate
  # Transform
  # Save
  # Send email
  # Log
  # Return
end

# DO: Break into smaller functions
def create_user(attrs) do
  with {:ok, validated} <- validate(attrs),
       {:ok, user} <- insert_user(validated),
       :ok <- send_welcome_email(user) do
    log_user_creation(user)
    {:ok, user}
  end
end

Single Responsibility

# Each function does one thing
defp validate(attrs), do: ...
defp insert_user(validated), do: ...
defp send_welcome_email(user), do: ...
defp log_user_creation(user), do: ...

Avoid Side Effects in Pipelines

# DON'T
data
|> transform()
|> tap(&IO.inspect/1)  # Side effect
|> validate()

# DO: Keep pipelines pure, side effects at the end
data =
  data
  |> transform()
  |> validate()

IO.inspect(data)
process(data)

Data Structures

Use Structs for Domain Models

# DON'T: Plain maps
def create_user(name, email) do
  %{name: name, email: email, created_at: DateTime.utc_now()}
end

# DO: Structs with enforced keys
defmodule User do
  @enforce_keys [:name, :email]
  defstruct [:name, :email, :created_at]

  def new(name, email) do
    %User{
      name: name,
      email: email,
      created_at: DateTime.utc_now()
    }
  end
end

Use Keywords for Options

# DO
def fetch(url, opts \\ []) do
  timeout = Keyword.get(opts, :timeout, 5000)
  headers = Keyword.get(opts, :headers, [])
  # ...
end

fetch("http://example.com", timeout: 10_000, headers: [{"Accept", "application/json"}])

Use Maps for Data

# For key-value data
user_data = %{
  "name" => "Alice",
  "email" => "alice@example.com"
}

# For structured data with atom keys
config = %{
  timeout: 5000,
  retry: true
}

Concurrency

Don't Share State

# DON'T
defmodule Counter do
  @count 0  # This won't work as expected

  def increment do
    @count = @count + 1  # Module attributes are compile-time
  end
end

# DO: Use processes
defmodule Counter do
  use GenServer

  def start_link(initial) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end

  def increment do
    GenServer.cast(__MODULE__, :increment)
  end

  def init(initial), do: {:ok, initial}

  def handle_cast(:increment, state) do
    {:noreply, state + 1}
  end
end

Use Task for One-off Work

# DO
Task.start(fn -> send_email(user) end)

# With supervision
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
  send_email(user)
end)

Use GenServer for State

# When you need to maintain state
defmodule Cache do
  use GenServer

  def get(key), do: GenServer.call(__MODULE__, {:get, key})
  def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})

  def init(_), do: {:ok, %{}}

  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end

  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end
end

Testing

Test Behavior, Not Implementation

# DON'T: Testing implementation details
test "calls internal function" do
  assert MyModule.internal_function() == :ok
end

# DO: Test public API
test "processes data correctly" do
  input = %{data: "test"}
  assert {:ok, result} = MyModule.process(input)
  assert result.processed == true
end

Use Descriptive Test Names

# DON'T
test "test1" do
end

# DO
test "creates user with valid attributes" do
end

test "returns error when email is invalid" do
end

One Assertion Per Test

# DON'T
test "user creation" do
  assert {:ok, user} = create_user(attrs)
  assert user.name == "Alice"
  assert user.email == "alice@example.com"
  assert user.age == 30
end

# DO
describe "create_user/1" do
  test "returns ok tuple" do
    assert {:ok, _user} = create_user(attrs)
  end

  test "sets name correctly" do
    {:ok, user} = create_user(attrs)
    assert user.name == "Alice"
  end

  test "sets email correctly" do
    {:ok, user} = create_user(attrs)
    assert user.email == "alice@example.com"
  end
end

Documentation

Module Documentation

defmodule MyApp.Accounts do
  @moduledoc """
  The Accounts context.

  Manages users, authentication, and authorization.
  """

  # ...
end

Function Documentation

@doc """
Creates a new user.

Returns `{:ok, user}` if successful, `{:error, changeset}` otherwise.

## Examples

    iex> create_user(%{name: "Alice", email: "alice@example.com"})
    {:ok, %User{}}

    iex> create_user(%{name: nil})
    {:error, %Ecto.Changeset{}}
"""
def create_user(attrs) do
  # ...
end

Type Specs

@type user :: %User{
  name: String.t(),
  email: String.t(),
  age: integer()
}

@spec create_user(map()) :: {:ok, user()} | {:error, Ecto.Changeset.t()}
def create_user(attrs) do
  # ...
end

Performance

Use Streams for Large Data

# DON'T: Loads entire file into memory
File.read!("huge_file.txt")
|> String.split("\n")
|> Enum.map(&process_line/1)

# DO: Streams process lazily
File.stream!("huge_file.txt")
|> Stream.map(&process_line/1)
|> Enum.to_list()

Batch Database Operations

# DON'T
Enum.each(users, fn user ->
  Repo.insert(%Post{user_id: user.id, title: "Welcome"})
end)

# DO
posts = Enum.map(users, fn user ->
  %Post{user_id: user.id, title: "Welcome"}
end)

Repo.insert_all(Post, posts)

Preload Associations

# DON'T: N+1 queries
users = Repo.all(User)
Enum.map(users, fn user ->
  user = Repo.preload(user, :posts)
  # Use user.posts
end)

# DO: Single query
users = User |> Repo.all() |> Repo.preload(:posts)

Common Anti-patterns

Avoid If/Else Chains

# DON'T
def handle(type) do
  if type == :a do
    handle_a()
  else
    if type == :b do
      handle_b()
    else
      if type == :c do
        handle_c()
      else
        :error
      end
    end
  end
end

# DO
def handle(:a), do: handle_a()
def handle(:b), do: handle_b()
def handle(:c), do: handle_c()
def handle(_), do: :error

Avoid Deeply Nested Code

# DON'T
def process(data) do
  if valid?(data) do
    result = transform(data)
    if result.status == :ok do
      if can_save?(result) do
        save(result)
      end
    end
  end
end

# DO
def process(data) do
  with :ok <- validate(data),
       {:ok, result} <- transform(data),
       :ok <- check_can_save(result) do
    save(result)
  end
end

Don't Abuse Macros

# DON'T: Macro when function would work
defmacro add(a, b) do
  quote do
    unquote(a) + unquote(b)
  end
end

# DO: Use function
def add(a, b), do: a + b

# Use macros for DSLs and compile-time code generation
defmacro test(description, do: block) do
  # ...
end

Code Formatting

Use mix format

# Format all files
mix format

# Check if formatted
mix format --check-formatted

Configure formatter

# .formatter.exs
[
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
  line_length: 100,
  import_deps: [:ecto, :phoenix]
]

Security Best Practices

Never Trust User Input

# DO: Validate and sanitize
def create_post(attrs) do
  %Post{}
  |> Post.changeset(attrs)
  |> Repo.insert()
end

def changeset(post, attrs) do
  post
  |> cast(attrs, [:title, :body])
  |> validate_required([:title, :body])
  |> validate_length(:title, max: 255)
end

Use Parameterized Queries

# DON'T: SQL injection risk
Repo.query("SELECT * FROM users WHERE email = '#{email}'")

# DO
Repo.query("SELECT * FROM users WHERE email = $1", [email])

# Better: Use Ecto
User |> where(email: ^email) |> Repo.all()

Store Secrets Securely

# DON'T: Hardcode secrets
@api_key "secret_key_12345"

# DO: Use environment variables
@api_key System.get_env("API_KEY")

# Or runtime config
config :myapp, api_key: System.get_env("API_KEY")

Practical Examples

Well-Structured Context

defmodule MyApp.Blog do
  @moduledoc """
  The Blog context.
  """

  import Ecto.Query
  alias MyApp.Repo
  alias MyApp.Blog.Post

  @doc """
  Returns the list of posts.
  """
  def list_posts do
    Post
    |> order_by(desc: :inserted_at)
    |> Repo.all()
  end

  @doc """
  Gets a single post.

  Raises `Ecto.NoResultsError` if the Post does not exist.
  """
  def get_post!(id), do: Repo.get!(Post, id)

  @doc """
  Creates a post.
  """
  def create_post(attrs \\ %{}) do
    %Post{}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a post.
  """
  def update_post(%Post{} = post, attrs) do
    post
    |> Post.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a post.
  """
  def delete_post(%Post{} = post) do
    Repo.delete(post)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking post changes.
  """
  def change_post(%Post{} = post, attrs \\ %{}) do
    Post.changeset(post, attrs)
  end
end

Idiomatic GenServer

defmodule MyApp.Cache do
  use GenServer
  require Logger

  # Client API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def get(key) do
    GenServer.call(__MODULE__, {:get, key})
  end

  def put(key, value) do
    GenServer.cast(__MODULE__, {:put, key, value})
  end

  def clear do
    GenServer.cast(__MODULE__, :clear)
  end

  # Server Callbacks

  @impl true
  def init(_opts) do
    Logger.info("Starting cache")
    {:ok, %{}}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end

  def handle_cast(:clear, _state) do
    {:noreply, %{}}
  end

  @impl true
  def terminate(reason, _state) do
    Logger.info("Cache stopping: #{inspect(reason)}")
    :ok
  end
end

Exercises

  1. Refactor a function that uses if/else chains to use pattern matching

  2. Convert nested case statements into a with expression

  3. Identify and fix anti-patterns in a provided code sample

  4. Write proper documentation with examples for a module

  5. Optimize a function that performs N+1 database queries

# Solutions

# 1. Pattern matching refactor
# Before
def handle_response(response) do
  if response.status == 200 do
    {:ok, response.body}
  else
    if response.status == 404 do
      {:error, :not_found}
    else
      {:error, :unknown}
    end
  end
end

# After
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}
def handle_response(_), do: {:error, :unknown}

# 2. with expression
# Before
def process(data) do
  case validate(data) do
    {:ok, validated} ->
      case transform(validated) do
        {:ok, transformed} ->
          case save(transformed) do
            {:ok, saved} -> {:ok, saved}
            error -> error
          end
        error -> error
      end
    error -> error
  end
end

# After
def process(data) do
  with {:ok, validated} <- validate(data),
       {:ok, transformed} <- transform(validated),
       {:ok, saved} <- save(transformed) do
    {:ok, saved}
  end
end

# 3. Fix anti-patterns
# Before
defmodule BadExample do
  @count 0  # Won't work!

  def increment, do: @count = @count + 1

  def process(list) do
    result = Enum.map(list, fn x ->
      if x > 0 do
        if x < 100 do
          x * 2
        else
          x
        end
      else
        0
      end
    end)
    result
  end
end

# After
defmodule GoodExample do
  use GenServer

  def start_link(initial) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end

  def increment do
    GenServer.cast(__MODULE__, :increment)
  end

  def process(list) do
    Enum.map(list, &calculate/1)
  end

  defp calculate(x) when x > 0 and x < 100, do: x * 2
  defp calculate(x) when x >= 100, do: x
  defp calculate(_x), do: 0

  # GenServer callbacks
  def init(initial), do: {:ok, initial}
  def handle_cast(:increment, state), do: {:noreply, state + 1}
end

# 4. Documentation
defmodule MyApp.Calculator do
  @moduledoc """
  Provides basic mathematical operations.

  ## Examples

      iex> Calculator.add(1, 2)
      3

      iex> Calculator.divide(10, 2)
      {:ok, 5.0}
  """

  @doc """
  Adds two numbers.

  ## Examples

      iex> Calculator.add(2, 3)
      5

      iex> Calculator.add(-1, 1)
      0
  """
  @spec add(number(), number()) :: number()
  def add(a, b), do: a + b

  @doc """
  Divides two numbers.

  Returns `{:ok, result}` on success or `{:error, :division_by_zero}`.

  ## Examples

      iex> Calculator.divide(10, 2)
      {:ok, 5.0}

      iex> Calculator.divide(10, 0)
      {:error, :division_by_zero}
  """
  @spec divide(number(), number()) :: {:ok, float()} | {:error, :division_by_zero}
  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b), do: {:ok, a / b}
end

# 5. Fix N+1 queries
# Before (N+1 problem)
def list_users_with_posts do
  users = Repo.all(User)
  
  Enum.map(users, fn user ->
    posts = Repo.all(from p in Post, where: p.user_id == ^user.id)
    %{user | posts: posts}
  end)
end

# After (single query with preload)
def list_users_with_posts do
  User
  |> Repo.all()
  |> Repo.preload(:posts)
end

# Or with query
def list_users_with_posts do
  from(u in User, preload: [:posts])
  |> Repo.all()
end

Summary

Key principles for writing idiomatic Elixir:

  1. Embrace immutability - Don't fight it
  2. Pattern match early - Use function heads
  3. Let it crash - Trust supervisors
  4. Keep functions small - Single responsibility
  5. Use pipes wisely - For transformations
  6. Test behavior - Not implementation
  7. Document thoroughly - Future you will thank you
  8. Follow conventions - Use mix format
  9. Use OTP - Don't reinvent wheels
  10. Think in processes - Embrace concurrency

Additional Resources

Conclusion

You've completed the Elixir tutorial! You now have:

  • Strong foundations in Elixir syntax and semantics
  • Understanding of functional programming concepts
  • Knowledge of OTP and building fault-tolerant systems
  • Experience with Phoenix web framework
  • Testing and documentation skills
  • Best practices for writing maintainable code

Next Steps:

  1. Build real projects
  2. Contribute to open source
  3. Read "Programming Elixir" and "Designing Elixir Systems with OTP"
  4. Join the community
  5. Keep learning and experimenting!

Happy coding with Elixir! 🎉