Welcome to the world of Elixir programming!
This series explores the practices and patterns that make Elixir such a powerful and elegant language for building scalable, fault-tolerant applications.
Why Elixir Practices Matter
Elixir isn’t just another programming language—it’s a philosophy of writing concurrent, maintainable, and resilient code.
Built on the rock-solid Erlang VM (BEAM), Elixir combines the best of functional programming with practical tools for real-world applications.
Core Practices We’ll Explore
1. Immutability as a Foundation
In Elixir, data doesn’t change—it transforms. This fundamental practice eliminates entire categories of bugs and makes concurrent programming safer and more predictable.
# Instead of mutating
# Original list remains unchanged
# Creates new list and copies data from original
list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3]
2. Pattern Matching: Your New Best Friend
Pattern matching isn’t just a feature—it’s the Elixir way of thinking about data flow and control structures. It fundamentally changes how you approach problems.
Let’s look do “The Traditional Way vs The Elixir Way” comparison.
In traditional languages, you might write:
// JavaScript/Java style
function processResponse(response) {
if (response.status === "ok") {
return handleData(response.data);
} else if (response.status === "error") {
return logError(response.reason);
} else {
throw new Error("Unknown response");
}
}
// Or with try-catch
try {
const data = JSON.parse(response);
return processData(data);
} catch (error) {
return handleError(error);
}
In Elixir, pattern matching separates concerns elegantly:
# Each function clause handles a specific shape of data
def process_response({:ok, data}),
do: handle_data(data)
def process_response({:error, reason}),
do: log_error(reason)
def process_response(unknown),
do: {:error, "Unknown response: #{inspect(unknown)}"}
# Instead of try-catch, pattern match on results
Jason.decode(response)
{:ok, data} -> process_response({:ok, data})
{:error, error} -> process_response({:error, error})
unknown -> process_response(unknown)
end
# Or using pipes
# note: parentesis can be omitted if function takes 1 argument
# in another words Jason.decode/1, process_response/1
response
|> Jason.decode() # same as calling Jason.decode(response)
|> process_response() # same as calling process_response(decoded_response)
Why This Mental Shift Matters?
1. Explicit Intent: Each function clause declares exactly what data shape it handles
2. No Nested Conditionals: Complex if-else chains become clean, separate functions
3. Compile-Time Guarantees: The compiler warns about unmatched patterns
4. Self-Documenting: The function signatures show all possible inputs
# Traditional nested conditionals become...
def handle_user(%{role: "admin", status: "active"} = user),
do: grant_admin_access(user)
def handle_user(%{role: "admin", status: "suspended"}),
do: {:error, "Admin suspended"}
def handle_user(%{role: "user", verified: true} = user),
do: grant_user_access(user)
def handle_user(%{role: "user", verified: false}),
do: {:error, "Please verify email"}
def handle_user(_),
do: {:error, "Invalid user"}
3. Let It Crash Philosophy
Unlike defensive programming, Elixir embraces failure as a natural part of distributed systems.
This counterintuitive approach leads to more robust systems.
Let’s compare The Traditional Defensive Approach against Elixir Way
In most languages, we’re taught to handle every possible error:
// Defensive programming in traditional languages
function processOrder(order) {
if (!order) {
return { error: "Order is null" };
}
if (!order.items || order.items.length === 0) {
return { error: "Order has no items" };
}
if (!order.customer) {
return { error: "Order has no customer" };
}
if (!order.customer.creditCard) {
return { error: "Customer has no credit card" };
}
// ...50 more validation checks...
try {
return processPayment(order);
} catch (error) {
logError(error);
return { error: "Payment processing failed" };
}
}
The Elixir Way: Fail Fast, Recover Faster
# Let the process crash on bad data
def process_order(order) do
%{items: items, customer: %{credit_card: card}} = order
# Just do the work - if data is bad, it crashes
process_payment(items, card)
end
# Supervisor restarts the crashed process
defmodule OrderProcessor.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, :ok, opts)
end
def init(:ok) do
children = [
{OrderProcessor, restart: :transient}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
Why “Let It Crash” Works?
1. Isolation: Process crashes don’t affect other processes (that’s :one_for_one
parameter means)
2. Clean State: Restarted processes begin with fresh state
3. Simplified Code: No defensive clutter obscuring business logic
4. Self-Healing: Supervisors automatically restore service
# Instead of complex error handling everywhere:
defmodule ExternalService do
def fetch_data(url) do
# Make HTTP request
# If it fails, the process crashes
# Supervisor restarts it
# Next request might succeed
HTTPoison.get!(url) # The ! means it crashes on failure
end
end
# With supervision
defmodule ResilientApp do
use Application
def start(_type, _args) do
children = [
{ExternalService, restart: :transient, max_restarts: 3}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
The Mental Shift
- From: “I must handle every possible error”
- To: “I’ll handle expected outcomes; let supervisors handle the unexpected”
This isn’t about being careless—it’s about recognizing that in distributed systems, failure is inevitable, and recovery should be systematic rather than ad-hoc.
Of course you - developer can handle error cases by calling method without !
and handle result by doing pattern matching. But this time it’s called handling expected.
Main idea here is that unexpected failure does not crash overall system - since Supervisor takes handling of unexpected and restarting it. Think about Kubernetes watching service and restarting it if it crashes, but Supervisor doing it for free out of box without installing configuring any orchestrator.
4. Processes as Building Blocks
Every Elixir application is built from lightweight processes that communicate through message passing—no shared state, no locks, no headaches.
In traditional OOP languages, we think in terms of objects and shared memory:
// Java style - Managing multiple user carts with thread safety concerns
public class ShoppingCart {
private final List<Item> items = new ArrayList<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void addItem(Item item) {
lock.writeLock().lock();
try {
items.add(item);
} finally {
lock.writeLock().unlock();
}
}
public double getTotal() {
lock.readLock().lock();
try {
return items.stream().mapToDouble(Item::getPrice).sum();
} finally {
lock.readLock().unlock();
}
}
}
public class ShoppingCartService {
private final ConcurrentHashMap<String, ShoppingCart> userCarts = new ConcurrentHashMap<>();
public void addItem(String userId, Item item) {
// Each cart still needs internal synchronization
ShoppingCart cart = userCarts.computeIfAbsent(userId, k -> new ShoppingCart());
cart.addItem(item); // This method is synchronized internally
}
public double getTotal(String userId) {
ShoppingCart cart = userCarts.get(userId);
return cart != null ? cart.getTotal() : 0.0; // This method is synchronized internally
}
}
// Even with ConcurrentHashMap, we still need locks inside each cart
// Multiple threads modifying the same user's cart need coordination
Thread t1 = new Thread(() -> service.addItem("user_123", item1));
Thread t2 = new Thread(() -> service.addItem("user_123", item2)); // Same user!
// These threads will block each other despite being independent operations
In Elixir, each process is like a tiny, independent program:
# Each shopping cart is its own process
defmodule ShoppingCart do
use GenServer
# Start a new cart process
def start_link(user_id) do
GenServer.start_link(__MODULE__, %{user_id: user_id, items: []})
end
# Send messages to the cart process
def add_item(cart_pid, item) do
GenServer.cast(cart_pid, {:add_item, item})
end
def get_total(cart_pid) do
GenServer.call(cart_pid, :get_total)
end
# The cart process handles messages
def handle_cast({:add_item, item}, state) do
{:noreply, %{state | items: [item | state.items]}}
end
def handle_call(:get_total, _from, state) do
total = Enum.sum(Enum.map(state.items, & &1.price))
{:reply, total, state}
end
end
# Create a cart process for user_123
{:ok, cart} = ShoppingCart.start_link("user_123")
# Multiple concurrent operations on the same cart
# These all execute concurrently without blocking each other
Task.async(fn -> ShoppingCart.add_item(cart, %{name: "Book", price: 20}) end)
Task.async(fn -> ShoppingCart.add_item(cart, %{name: "Laptop", price: 1000}) end)
Task.async(fn -> ShoppingCart.get_total(cart) end)
# The cart process handles messages sequentially in its mailbox
# No explicit locks needed - the process mailbox provides natural serialization
# Why sequential processing?
# Each Elixir process has:
# 1. One mailbox (queue) for incoming messages
# 2. One execution thread that processes messages one at a time
The Mental Shift: From Objects to Actors
Traditional Thinking: - “I have data structures that I protect with locks” - “I create threads when I need parallelism” - “I worry about race conditions and deadlocks” - “Concurrency is hard and dangerous”
Process-Oriented Thinking: - “I have millions of tiny, isolated programs” - “Everything is already concurrent by default” - “Processes can only communicate through messages” - “Concurrency is natural and safe”
Understanding Process Mailboxes:
# Each process is like a worker with an inbox
defmodule Worker do
def start do
spawn(fn -> loop() end)
end
defp loop do
receive do # Check mailbox for next message
{:work, task} ->
IO.puts("Starting: #{task}")
Process.sleep(1000) # Simulate work
IO.puts("Finished: #{task}")
loop() # Continue processing next message
end
end
end
worker = Worker.start()
# These messages are queued instantly
send(worker, {:work, "Task 1"})
send(worker, {:work, "Task 2"})
send(worker, {:work, "Task 3"})
# Output shows sequential processing:
# Starting: Task 1
# Finished: Task 1
# Starting: Task 2
# Finished: Task 2
# Starting: Task 3
# Finished: Task 3
Real-World Analogy
Think of processes like people in an office:
# Traditional approach: One person with a shared notebook
# Everyone must wait their turn to write (locks)
# Elixir approach: Everyone has their own desk and notebook
# Communication happens through emails (messages)
defmodule Employee do
def start_link(name) do
spawn_link(fn -> work_loop(name, []) end)
end
defp work_loop(name, tasks) do
receive do
{:assign_task, task} ->
IO.puts("#{name} received task: #{task}")
work_loop(name, [task | tasks])
{:status_report, from} ->
send(from, {:report, name, length(tasks)})
work_loop(name, tasks)
:take_break ->
IO.puts("#{name} is taking a break")
Process.sleep(5000)
work_loop(name, tasks)
end
end
end
# Create a team
alice = Employee.start_link("Alice")
bob = Employee.start_link("Bob")
# They work independently
send(alice, {:assign_task, "Write report"})
send(bob, {:assign_task, "Fix bug"})
send(alice, :take_break) # Doesn't affect Bob!
Why This Changes Everything?
1. Natural Isolation: Each process failure is contained
2. True Concurrency: Not just parallel threads, but independent entities
3. Location Transparency: Processes can be local or on different machines
4. Predictable Behavior: No shared state means no race conditions
# Processes scale naturally
defmodule WebServer do
def handle_request(request) do
# Each request gets its own process
spawn(fn ->
result = process_request(request)
send_response(result)
end)
end
end
# Handling millions of users? Just spawn millions of processes
# The BEAM VM handles scheduling efficiently
5. Pipe Operator for Clarity
Transform nested function calls into readable pipelines that match how we think about data transformation.
In most languages, function composition reads backwards:
// JavaScript - Reading from inside out
const result = capitalize(
removeSpaces(
validateFormat(
trimWhitespace(
parseUserInput(input)
)
)
)
);
// Or with intermediate variables (verbose)
const parsed = parseUserInput(input);
const trimmed = trimWhitespace(parsed);
const validated = validateFormat(trimmed);
const spacesRemoved = removeSpaces(validated);
const result = capitalize(spacesRemoved);
The Elixir Way: Follow the Data Flow
# Read top to bottom, like a recipe
result =
input
|> parse_user_input()
|> trim_whitespace()
|> validate_format()
|> remove_spaces()
|> capitalize()
# The pipe operator |> passes the result of each function
# as the first argument to the next function
The Mental Shift: Think in Transformations
Traditional Thinking: - “What functions do I need to call?” - “How do I nest these function calls?” - “Let me work backwards from the result” - “I need temporary variables to keep it readable”
Pipeline Thinking: - “What transformations does my data go through?” - “Each step transforms the data and passes it forward” - “I can read the code like a story” - “The data flow is explicit and visual”
Real-World Example: Processing User Registration
# Traditional approach (hard to follow)
def register_user_traditional(params) do
save_to_db(
assign_default_role(
hash_password(
validate_user(
sanitize_input(params)
)
)
)
)
end
# Pipeline approach (reads like steps in a process)
def register_user(params) do
params
|> sanitize_input()
|> validate_user()
|> hash_password()
|> assign_default_role()
|> save_to_db()
|> send_welcome_email()
|> log_registration()
end
# Easy to:
# - Add steps: just add another line
# - Remove steps: just delete a line
# - Reorder: just move lines around
# - Debug: comment out individual steps
Why This Changes How You Code?
1. Natural Reading Order: Code reads top-to-bottom like prose
2. Explicit Data Flow: You can trace data transformation visually
3. Easy Refactoring: Adding/removing/reordering steps is trivial
4. Debugging Friendly: Insert IO.inspect()
anywhere in the pipeline
5. Composable: Build complex operations from simple transformations
# Debug by inserting inspection at any point
result = input
|> parse_user_input()
|> IO.inspect(label: "after parsing")
|> trim_whitespace()
|> IO.inspect(label: "after trim")
|> validate_format()
The pipe operator isn’t just syntax sugar—it fundamentally changes how you decompose problems into a series of data transformations.
The Journey Ahead
This series will guide you through: - Understanding Elixir’s core concepts - Mastering the interactive shell (IEx) - Building projects with Mix - Writing idiomatic Elixir code - Designing fault-tolerant systems
Whether you’re coming from object-oriented languages or other functional languages, Elixir’s practices will challenge and reward you.
The initial learning curve pays dividends in productivity, maintainability, and system reliability.
Ready to think in processes, embrace immutability, and let it crash?
Let’s begin this journey into Elixir’s elegant world of concurrent programming!