Powered by AppSignal & Oban Pro

Part 2: The Language

slides/02-the-language.livemd

Part 2: The Language

What Makes Elixir Click

Just a couple of “aha” moments that make Elixir different.

We’ll cover three things:

  1. Pattern Matching — a feature that changes how you think about code
  2. Processes & Concurrency — lightweight, isolated, fault-tolerant
  3. LiveView — real-time web UIs without JavaScript

Pattern Matching: The Basics

In most languages, = means assignment. In Elixir, = means match. It works like destructuring, but it also validates the shape of your data.

# Destructure a tuple
{status, message} = {:ok, "Hello, GR Web Dev!"}

IO.puts("Status: #{status}")
IO.puts("Message: #{message}")
# Destructure a map — pull out just what you need
user = %{name: "Torey", role: :developer, city: "Grand Rapids"}

%{name: name, city: city} = user

IO.puts("#{name} from #{city}")
# Match on a specific value — this is where it gets interesting
{:ok, result} = {:ok, 42}

IO.puts("Got: #{result}")
# What happens when the match FAILS?

{:ok, result} = {:error, "something went wrong"}

That’s not a bug — it’s a feature. If you expect :ok and get :error, Elixir tells you immediately instead of silently continuing with bad data.

JavaScript: Similar to destructuring — but it also validates the shape of your data at the same time.

Pattern Matching: Function Heads

In Elixir, you can define the same function multiple times with different patterns. Elixir picks the first one that matches.

At first I thought this was a bad idea, calling/creating a different function, based on the args… but then it clicked!

Instead of creating if/case statements to handle different inputs with a single function, you can create focused functions based on the inputs.

defmodule Greeter do
  # If we get an :ok tuple, celebrate
  def respond({:ok, data}) do
    "Success! Got: #{inspect(data)}"
  end

  # If we get an :error tuple, handle it
  def respond({:error, reason}) do
    "Something went wrong: #{reason}"
  end

  # Catch-all for anything else
  def respond(other) do
    "Unexpected: #{inspect(other)}"
  end
end
Greeter.respond({:ok, [1, 2, 3]})
Greeter.respond({:error, "not found"})
Greeter.respond("something random")

No if/else. No switch. Each function clause handles one case, and the code reads like a specification.

Pattern Matching: A Practical Example

Here’s something closer to real code. Imagine handling different shapes of API responses:

defmodule ApiHandler do
  # Success with data
  def handle_response(%{status: 200, body: body}) do
    {:ok, body}
  end

  # Created
  def handle_response(%{status: 201, body: body}) do
    {:ok, body}
  end

  # Not found
  def handle_response(%{status: 404}) do
    {:error, :not_found}
  end

  # Rate limited — note the header extraction
  def handle_response(%{status: 429, headers: headers}) do
    retry_after = Map.get(headers, "retry-after", "60")
    {:rate_limited, String.to_integer(retry_after)}
  end

  # Server error
  def handle_response(%{status: status}) when status >= 500 do
    {:error, :server_error}
  end

  # Anything else
  def handle_response(%{status: status}) do
    {:error, {:unexpected_status, status}}
  end
end
# Try different responses:
ApiHandler.handle_response(%{status: 200, body: %{users: ["Alice", "Bob"]}})
ApiHandler.handle_response(%{status: 429, headers: %{"retry-after" => "30"}})

No if/else chains, no switch statements. Each clause is self-documenting.

Pattern Matching: The with Statement

Real-world code often has multiple steps that can each fail. In other languages there’s an Interactor pattern that often requires a whole library to implement. Elixir has the built-in with statement

defmodule OrderProcessor do
  def process_order(order_id) do
    with( 
          {:ok, order} <- find_order(order_id),
         {:ok, _} <- validate_inventory(order),
         {:ok, charge} <- charge_payment(order),
         {:ok, shipment} <- create_shipment(order) 
    ) do
      {:ok, %{order: order, charge: charge, shipment: shipment}}
    else
      {:error, :not_found} -> {:error, "Order not found"}
      {:error, :out_of_stock} -> {:error, "Item is out of stock"}
      {:error, :payment_declined} -> {:error, "Payment was declined"}
      {:error, reason} -> {:error, "Unexpected error: #{inspect(reason)}"}
    end
  end

  # Simulated steps — in production these hit databases and APIs
  defp find_order(1), do: {:ok, %{id: 1, item: "Elixir Book", amount: 29_99}}
  defp find_order(2), do: {:ok, %{id: 2, item: "Sold Out Tee", amount: 19_99}}
  defp find_order(3), do: {:ok, %{id: 3, item: "Elixir Book", amount: 99_99}}
  defp find_order(_), do: {:error, :not_found}

  defp validate_inventory(%{item: "Elixir Book"}), do: {:ok, :in_stock}
  defp validate_inventory(_), do: {:error, :out_of_stock}

  defp charge_payment(%{amount: amount}) when amount < 50_00, do: {:ok, %{charge_id: "ch_123", amount: amount}}
  defp charge_payment(_), do: {:error, :payment_declined}

  defp create_shipment(order), do: {:ok, %{tracking: "1Z999AA10123456784", order_id: order.id}}
end
# Happy path — all steps succeed
OrderProcessor.process_order(1)
# Out of stock — short circuits at validate_inventory
OrderProcessor.process_order(2)

The with statement chains operations that return {:ok, value} or {:error, reason}. If any step fails, it jumps straight to the else block.

Processes: The Heart of Elixir

Everything in Elixir runs inside processes — not OS processes, not threads. They’re ~2KB each, completely isolated, with their own memory and garbage collection.

# Spawn a process — it runs concurrently
spawn(fn ->
  IO.puts("Hello from process #{inspect(self())}")
end)

IO.puts("Hello from the main process #{inspect(self())}")
# Let's spawn 10,000 just to show it's trivial.
{time_microseconds, _} =
  :timer.tc(fn ->
    1..10_000
    |> Enum.map(fn i ->
      spawn(fn -> i * i end)
    end)
  end)

IO.puts("Spawned 10,000 processes in #{time_microseconds / 1_000}ms")

Processes: Supervisors — “Let It Crash”

What happens when a process crashes? In most languages, you write defensive code to prevent every possible error. In Elixir, you let it crash and have a Supervisor automatically restart it.

defmodule UnreliableWorker do
  use GenServer

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

  def get_count(pid), do: GenServer.call(pid, :get_count)
  def do_work(pid), do: GenServer.call(pid, :do_work)

  @impl true
  def init(_opts) do
    IO.puts("  [Worker] Started! (PID: #{inspect(self())})")
    {:ok, %{count: 0}}
  end

  @impl true
  def handle_call(:get_count, _from, state) do
    {:reply, state.count, state}
  end

  @impl true
  def handle_call(:do_work, _from, state) do
    new_count = state.count + 1

    if new_count >= 3 do
      # Simulate a crash on the 3rd call
      raise "Boom! Something went wrong on call ##{new_count}"
    end

    IO.puts("  [Worker] Completed work ##{new_count}")
    {:reply, {:ok, new_count}, %{state | count: new_count}}
  end
end
# Start a Supervisor watching our unreliable worker
children = [
  {UnreliableWorker, name: :my_worker}
]

{:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one)

IO.puts("--- Doing work ---")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 1")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 2")

IO.puts("\n--- This next call will crash the worker ---")

try do
  UnreliableWorker.do_work(:my_worker)
catch
  :exit, _ -> IO.puts("  [Caller] The worker crashed!")
end

# Give the supervisor a moment to restart the worker
Process.sleep(100)

IO.puts("\n--- Worker was automatically restarted by the Supervisor ---")
IO.puts("--- Notice: it's a NEW process with fresh state ---")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 1 (restarted)")
UnreliableWorker.get_count(:my_worker) |> IO.inspect(label: "Count")

What just happened:

  1. The worker crashed on the 3rd call
  2. The Supervisor detected the crash
  3. The Supervisor started a brand new worker automatically
  4. The new worker has fresh state (count = 0)
  5. Everything keeps running — the crash was isolated and recovered

This is the “let it crash” philosophy. Instead of writing defensive code for every possible failure, you design for recovery. Your Supervisor is like a manager who automatically fixes problems instead of you writing code to prevent every possible issue.

In production at Vianet: Our email rate limiter, database connections, TCP connections to Verisign — they’re all supervised. If any of them crash, they restart automatically. The application keeps running.

LiveView: Real-Time Web Without JavaScript

LiveView is a Phoenix framework feature that gives you real-time, interactive UIs rendered entirely on the server.

How It Works

┌──────────────┐                    ┌──────────────┐
│   Browser    │                    │    Server    │
│              │   1. HTTP GET      │              │
│              │ ──────────────────>│              │
│              │   Full HTML page   │              │
│              │ <──────────────────│              │
│              │                    │              │
│              │   2. WebSocket     │              │
│              │ <════════════════> │              │
│              │                    │              │
│  User clicks │   3. Event sent    │              │
│  a button    │ ──────────────────>│  Processes   │
│              │                    │  the event,  │
│              │   4. HTML diff     │  re-renders  │
│              │ <──────────────────│              │
│  Browser     │                    │              │
│  patches DOM │                    │              │
└──────────────┘                    └──────────────┘
  1. First page load is regular HTML — fast, SEO-friendly
  2. WebSocket connection upgrades the page to real-time
  3. User interactions send tiny events over the WebSocket
  4. Server re-renders and sends back only the HTML that changed

What the Code Looks Like

Here’s a simplified version of a real search feature from one of our apps:

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:query, "")
     |> assign(:results, [])
  end

  # User types in the search box
  # Search runs asynchronously
  def handle_event("search", %{"query" => query}, socket) do
    results = MyApp.Search.find(query)

    {:noreply,
     socket
     |> assign(:results, results)
  end
end

Recap

  • Pattern matching — function heads replace if/else, with chains operations with error handling
  • Processes — lightweight (~2KB), isolated, supervised — “let it crash” instead of defensive code
  • LiveView — real-time UI without JavaScript, server renders HTML diffs over WebSocket

Next up: Let’s see these patterns in production code…