Last modified: July 17, 2025
·
8 min read

Advanced Concepts and Mix Projects - Learn Elixir Part 3

Now that you're comfortable with Elixir's basics, let's tackle some more advanced concepts. This article covers recursion, error handling, file operations, and project management. These topics will help you write more robust and maintainable Elixir code.

Thinking in Recursion

In functional programming, we don't use traditional loops. Instead, we use recursion - functions that call themselves. This might seem strange at first, but it's actually quite elegant once you get the hang of it.

Why Recursion?

Recursion fits naturally with functional programming because:

  • It works well with immutable data
  • It's easier to reason about than loops with changing state
  • It can be optimized by the compiler

Let's start with a simple example - counting down:

defmodule Recursion do
  def countdown(0), do: IO.puts("Blast off!")
  def countdown(n) do
    IO.puts(n)
    countdown(n - 1)
  end
end

Here's what happens when you call countdown(3):

  1. Prints "3"
  2. Calls countdown(2)
  3. Prints "2"
  4. Calls countdown(1)
  5. Prints "1"
  6. Calls countdown(0)
  7. Prints "Blast off!"

The key is the base case (countdown(0)) - it stops the recursion.

Working with Lists

Lists are perfect for recursion because they're built from a head and tail:

defmodule ListOperations do
  # Sum all numbers in a list
  def sum([]), do: 0  # Base case: empty list
  def sum([head | tail]) do
    head + sum(tail)  # Add head to sum of tail
  end

  # Find the length of a list
  def length([]), do: 0
  def length([_head | tail]) do
    1 + length(tail)  # Count this element plus rest
  end
end

The [head | tail] pattern is crucial here. It splits the list into the first element and everything else.

Tail Recursion - The Efficient Way

Regular recursion can cause stack overflow with large data. Tail recursion fixes this by making the recursive call the last operation:

defmodule TailRecursion do
  # Regular factorial (can cause stack overflow)
  def factorial(0), do: 1
  def factorial(n) when n > 0 do
    n * factorial(n - 1)  # Multiplication happens after recursive call
  end

  # Tail recursive factorial (optimized)
  def factorial_tail(n), do: factorial_tail(n, 1)
  
  def factorial_tail(0, acc), do: acc
  def factorial_tail(n, acc) when n > 0 do
    factorial_tail(n - 1, n * acc)  # Recursive call is last operation
  end
end

The difference is subtle but important. In tail recursion, we carry an accumulator (acc) that builds up the result, and the recursive call is the final operation. This lets the compiler optimize it into a loop.

List Comprehensions - Elegant Data Transformation

List comprehensions give you a concise way to transform and filter data. Think of them as a more readable alternative to nested loops.

Basic Comprehensions

numbers = [1, 2, 3, 4, 5]

# Double each number
doubled = for n <- numbers, do: n * 2
# Result: [2, 4, 6, 8, 10]

The for keyword creates a new list by applying the expression after do: to each element.

Adding Filters

You can add conditions to filter elements:

# Get only even numbers
evens = for n <- numbers, rem(n, 2) == 0, do: n
# Result: [2, 4]

The rem(n, 2) == 0 part is a filter - only elements that make this true are included.

Multiple Generators

You can combine multiple lists:

# Create all combinations
pairs = for x <- [1, 2], y <- [3, 4], do: {x, y}
# Result: [{1, 3}, {1, 4}, {2, 3}, {2, 4}]

This is like nested loops - for each value of x, it goes through all values of y.

Pattern Matching in Comprehensions

You can use pattern matching to extract specific parts:

users = [%{name: "Alice", age: 30}, %{name: "Bob", age: 25}]
names = for %{name: name} <- users, do: name
# Result: ["Alice", "Bob"]

This extracts just the name field from each user map.

Error Handling - The Elixir Way

Elixir has a "let it crash" philosophy, but that doesn't mean you ignore errors. Instead, you handle them at the right level.

When Things Go Wrong

Elixir provides several ways to handle errors:

  1. Pattern matching - Check return values
  2. Exceptions - For truly exceptional cases
  3. Supervisors - Let processes crash and restart (we'll cover this later)

Using Pattern Matching

Most Elixir functions return {:ok, result} or {:error, reason}:

defmodule SafeOperations do
  def divide(a, b) when b == 0, do: {:error, "Cannot divide by zero"}
  def divide(a, b), do: {:ok, a / b}

  def read_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "Failed to read file: #{reason}"}
    end
  end
end

This approach is preferred because it's explicit and forces you to handle both success and failure cases.

The with Expression

When you have multiple operations that might fail, with is your friend:

def process_user_data(user_id) do
  with {:ok, user} <- get_user(user_id),
       {:ok, profile} <- get_profile(user.profile_id),
       {:ok, preferences} <- get_preferences(user.preferences_id) do
    {:ok, %{user: user, profile: profile, preferences: preferences}}
  else
    {:error, reason} -> {:error, reason}
  end
end

The with expression tries each operation in sequence. If any fails, it stops and executes the else block. If all succeed, it executes the main block.

Exceptions for Exceptional Cases

Exceptions should be rare in Elixir. Use them for things that shouldn't happen:

defmodule Validation do
  defmodule ValidationError do
    defexception message: "Validation failed"
  end

  def validate_age(age) when age < 0 do
    raise ValidationError, message: "Age cannot be negative"
  end
  
  def validate_age(age) when age > 150 do
    raise ValidationError, message: "Age seems unrealistic"
  end
  
  def validate_age(_age), do: :ok
end

Try-Rescue for External Code

Use try-rescue when calling external libraries or code you don't control:

def safe_divide(a, b) do
  try do
    result = a / b
    {:ok, result}
  rescue
    ArithmeticError -> {:error, "Division by zero"}
    e in RuntimeError -> {:error, e.message}
    _ -> {:error, "Unknown error occurred"}
  end
end

The rescue block catches exceptions and converts them to your preferred error format.

Working with Files

Elixir makes file operations straightforward and safe.

Reading Files

defmodule FileReader do
  def read_entire_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "Failed to read file: #{reason}"}
    end
  end

  def read_file_lines(path) do
    File.stream!(path)
    |> Stream.map(&String.trim/1)
    |> Stream.filter(&(&1 != ""))
    |> Enum.to_list()
  end
end

The File.stream! function is great for large files because it reads line by line instead of loading everything into memory.

Writing Files

def write_log_entry(message) do
  timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
  log_entry = "[#{timestamp}] #{message}\n"
  
  case File.write("app.log", log_entry, [:append]) do
    :ok -> {:ok, "Log entry written"}
    {:error, reason} -> {:error, "Failed to write log: #{reason}"}
  end
end

The [:append] option adds to the file instead of overwriting it.

Processes - Elixir's Concurrency Model

Elixir's processes are lightweight and isolated. They're not OS processes - they're managed by the BEAM virtual machine.

Spawning Processes

defmodule ProcessBasics do
  def spawn_example do
    pid = spawn(fn -> 
      IO.puts("Hello from process #{inspect(self())}")
    end)
    
    IO.puts("Spawned process: #{inspect(pid)}")
  end
end

The spawn function creates a new process that runs the given function. The process runs independently and can crash without affecting others.

Message Passing

Processes communicate by sending messages:

defmodule MessageExample do
  def start_receiver do
    spawn(fn -> 
      receive do
        {:hello, name} -> 
          IO.puts("Hello, #{name}!")
        {:goodbye, name} -> 
          IO.puts("Goodbye, #{name}!")
        _ -> 
          IO.puts("Unknown message")
      end
    end)
  end

  def send_messages do
    pid = start_receiver()
    send(pid, {:hello, "Alice"})
    send(pid, {:goodbye, "Alice"})
  end
end

The receive block waits for messages and pattern matches on them. The send function sends a message to a process.

Stateful Processes

Processes can maintain state by calling themselves recursively:

defmodule Counter do
  def start do
    spawn(fn -> counter_loop(0) end)
  end

  defp counter_loop(count) do
    receive do
      :increment -> 
        counter_loop(count + 1)
      {:get, caller} -> 
        send(caller, {:count, count})
        counter_loop(count)
      :stop -> 
        :ok
    end
  end

  def increment(pid) do
    send(pid, :increment)
  end

  def get_count(pid) do
    send(pid, {:get, self()})
    receive do
      {:count, value} -> value
    end
  end
end

This counter maintains its state by passing the count as a parameter to the recursive counter_loop function.

Mix - Your Project Manager

Mix is Elixir's build tool. It handles dependencies, compilation, testing, and more.

Creating a New Project

# Basic project
mix new my_app

# Project with supervision tree
mix new my_app --sup

# Umbrella project (multiple apps)
mix new my_umbrella --umbrella

The --sup flag creates a supervision tree, which we'll cover in the next article.

Project Structure

A typical Mix project looks like this:

my_app/
├── lib/                    # Your source code
│   ├── my_app.ex          # Main application module
│   └── my_app/            # Other modules
├── test/                   # Tests
│   ├── my_app_test.exs
│   └── test_helper.exs
├── mix.exs                 # Project configuration
├── mix.lock                # Locked dependency versions
└── README.md

Configuring Your Project

The mix.exs file is where you configure your project:

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      mod: {MyApp.Application, []}
    ]
  end

  defp deps do
    [
      # Add your dependencies here
      {:httpoison, "~> 2.0"},
      {:jason, "~> 1.4"}
    ]
  end
end

The project/0 function defines build settings, application/0 defines how your app starts, and deps/0 lists your dependencies.

Common Mix Commands

# Install dependencies
mix deps.get

# Compile your code
mix compile

# Run tests
mix test

# Start interactive shell with your app loaded
iex -S mix

# Format your code
mix format

# Generate documentation
mix docs

Testing Your Code

Elixir comes with excellent testing tools built-in.

Writing Tests

defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  test "greeting returns hello" do
    assert MyApp.greeting() == "Hello, World!"
  end

  test "greeting with name" do
    assert MyApp.greeting("Alice") == "Hello, Alice!"
  end

  test "division by zero returns error" do
    assert {:error, _} = MyApp.divide(10, 0)
  end
end

The test macro defines a test case. The assert macro checks that something is true and fails the test if it's not.

Test Setup

You can set up data that multiple tests need:

defmodule UserTest do
  use ExUnit.Case

  setup do
    user = %{name: "Alice", age: 30}
    {:ok, user: user}
  end

  test "user validation", %{user: user} do
    assert MyApp.validate_user(user) == :ok
  end

  test "user with invalid age", %{user: user} do
    invalid_user = %{user | age: -5}
    assert {:error, _} = MyApp.validate_user(invalid_user)
  end
end

The setup block runs before each test and provides data to the test function.

Documenting Your Code

Elixir makes it easy to write good documentation.

Module Documentation

defmodule MyApp do
  @moduledoc """
  A sample Elixir application.
  
  This module provides basic functionality for greeting users
  and performing mathematical operations.
  """

  @doc """
  Returns a greeting message.
  
  ## Examples
      
      iex> MyApp.greeting()
      "Hello, World!"
      
      iex> MyApp.greeting("Alice")
      "Hello, Alice!"
  """
  def greeting(name \\ "World") do
    "Hello, #{name}!"
  end

  @doc """
  Divides two numbers safely.
  
  Returns `{:ok, result}` on success or `{:error, reason}` on failure.
  """
  def divide(a, b) when b == 0, do: {:error, "Cannot divide by zero"}
  def divide(a, b), do: {:ok, a / b}
end

The @moduledoc documents the entire module, while @doc documents individual functions. The examples in the documentation can be run as tests with mix test.

What's Next?

You're getting comfortable with Elixir's advanced features! In the next article, we'll explore OTP (Open Telecom Platform), which is where Elixir really shines. We'll cover:

  • GenServer for stateful processes
  • Supervisors for fault tolerance
  • Building web applications with Phoenix
  • Working with databases using Ecto

Practice Exercises

Try these exercises to reinforce what you've learned:

  1. Recursion Practice: Write a function that finds the maximum value in a list using recursion
  2. List Comprehensions: Use list comprehensions to find all prime numbers up to 100
  3. Error Handling: Create a function that safely reads a JSON file and parses it
  4. Process Communication: Build a simple chat system using processes and message passing

Key Points

  • Recursion is the primary way to handle loops in functional programming
  • Tail recursion is more efficient and prevents stack overflow
  • List comprehensions provide elegant data transformation
  • Pattern matching is preferred over exceptions for error handling
  • The with expression chains operations that might fail
  • Processes provide lightweight concurrency
  • Mix manages your project's dependencies and build process
  • ExUnit provides comprehensive testing tools
  • Good documentation includes examples that can be run as tests