Advanced control structures in Elixir allow you to build complex execution flows in an elegant and functional way. This article explores how to combine different control structures to create more expressive and robust code.
Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Combining Control Structures
- Functional Patterns for Flow Control
- Error Handling Strategies
- Railway Oriented Programming
- State Machines in Elixir
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
In previous articles, we explored various control structures in Elixir:
- Atoms, booleans, and nil as fundamentals
-
if
andunless
for simple conditional logic -
case
for pattern matching against values -
cond
for evaluating multiple conditions -
with
for chaining dependent operations - Guards for extending pattern matching with validations
Now, we'll see how to combine these structures to create advanced control flows that solve complex problems. We'll explore common patterns in functional programming, error handling strategies, and techniques for managing state in Elixir applications.
Combining Control Structures
Often, the most elegant solution to a problem involves combining different control structures.
Pattern Matching + Guards + Case
defmodule PaymentProcessor do def process_payment(payment) do case validate_payment(payment) do {:ok, %{amount: amount, currency: currency} = validated} when amount > 1000 -> with {:ok, _} <- authorize_large_payment(validated), {:ok, transaction} <- execute_payment(validated) do {:ok, transaction} else {:error, :authorization_failed} -> {:error, "Payment requires manual approval"} error -> error end {:ok, validated} when validated.currency != "USD" -> cond do validated.currency in supported_currencies() -> {:ok, transaction} = execute_payment(validated) {:ok, transaction} true -> {:error, "Currency not supported: #{validated.currency}"} end {:ok, validated} -> execute_payment(validated) {:error, reason} -> {:error, "Validation failed: #{reason}"} end end defp validate_payment(%{amount: amount, currency: currency}) when is_number(amount) and is_binary(currency) and amount > 0 do {:ok, %{amount: amount, currency: currency}} end defp validate_payment(_), do: {:error, "Invalid payment data"} defp authorize_large_payment(%{amount: amount}) when amount > 5000, do: {:error, :authorization_failed} defp authorize_large_payment(_), do: {:ok, :authorized} defp execute_payment(payment), do: {:ok, %{id: "tx_#{:rand.uniform(1000)}", payment: payment}} defp supported_currencies, do: ["USD", "EUR", "GBP"] end
Let's test in IEx:
# Normal payment iex> PaymentProcessor.process_payment(%{amount: 500, currency: "USD"}) {:ok, %{id: "tx_578", payment: %{currency: "USD", amount: 500}}} # Large payment (requires authorization) iex> PaymentProcessor.process_payment(%{amount: 1200, currency: "USD"}) {:ok, %{id: "tx_68", payment: %{currency: "USD", amount: 1200}}} # Very large payment (authorization fails) iex> PaymentProcessor.process_payment(%{amount: 6000, currency: "USD"}) {:error, "Payment requires manual approval"} # Different currency (supported) iex> PaymentProcessor.process_payment(%{amount: 500, currency: "EUR"}) {:ok, %{id: "tx_441", payment: %{currency: "EUR", amount: 500}}} # Unsupported currency iex> PaymentProcessor.process_payment(%{amount: 500, currency: "JPY"}) {:error, "Currency not supported: JPY"} # Invalid data iex> PaymentProcessor.process_payment(%{amount: -100, currency: "USD"}) {:error, "Validation failed: Invalid payment data"}
This example illustrates how to combine pattern matching, guards, case
, with
, and cond
to handle different scenarios in a payment processor. Each control structure is used where it offers the best expressiveness:
-
case
for the main flow based on validation result - Guards to filter by value and currency
-
with
to handle the authorization and execution flow sequentially -
cond
to check if the currency is supported
Functional Patterns for Flow Control
Functional programming offers elegant patterns for flow control that go beyond basic structures.
Higher-Order Functions
defmodule Pipeline do def map_if(data, condition, mapper) do if condition.(data) do mapper.(data) else data end end def filter_map(list, filter_fn, map_fn) do list |> Enum.filter(filter_fn) |> Enum.map(map_fn) end def apply_transforms(data, transforms) do Enum.reduce(transforms, data, fn transform, acc -> transform.(acc) end) end end
Test it in IEx:
iex> Pipeline.map_if(10, &(&1 > 5), &(&1 * 2)) 20 iex> Pipeline.map_if(3, &(&1 > 5), &(&1 * 2)) 3 iex> Pipeline.filter_map(1..10, &(rem(&1, 2) == 0), &(&1 * &1)) [4, 16, 36, 64, 100] iex> transforms = [ &(&1 + 5), &(&1 * 2), &(&1 - 1) ] iex> Pipeline.apply_transforms(10, transforms) 29 # ((10 + 5) * 2) - 1 = 29
Function Composition
defmodule Composition do def compose(f, g) do fn x -> f.(g.(x)) end end def pipe_functions(initial, functions) do Enum.reduce(functions, initial, fn f, acc -> f.(acc) end) end # Practical example: text processing def normalize_text(text) do functions = [ &String.downcase/1, &String.trim/1, &remove_special_chars/1, &collapse_whitespace/1 ] pipe_functions(text, functions) end defp remove_special_chars(text) do Regex.replace(~r/[^a-zA-Z0-9\s]/, text, "") end defp collapse_whitespace(text) do Regex.replace(~r/\s+/, text, " ") end end
Test it in IEx:
iex> add_one = &(&1 + 1) iex> multiply_by_two = &(&1 * 2) iex> composed = Composition.compose(add_one, multiply_by_two) iex> composed.(5) 11 # add_one(multiply_by_two(5)) = add_one(10) = 11 iex> Composition.normalize_text(" Hello, World! How are you? ") "hello world how are you"
Error Handling Strategies
Elixir allows implementing various error handling strategies that leverage the pattern matching model.
Hierarchical Error Handling
defmodule ErrorHandling do def execute_operation(operation, args) do try do apply_operation(operation, args) rescue e in ArithmeticError -> {:error, :math_error, e.message} e in FunctionClauseError -> {:error, :invalid_input, "Invalid input for #{operation}"} e -> {:error, :unexpected, e.message} end end defp apply_operation(:divide, [a, b]) when is_number(a) and is_number(b) and b != 0, do: {:ok, a / b} defp apply_operation(:sqrt, [x]) when is_number(x) and x >= 0, do: {:ok, :math.sqrt(x)} defp apply_operation(:log, [x]) when is_number(x) and x > 0, do: {:ok, :math.log(x)} def process_result(result) do case result do {:ok, value} -> "Result: #{value}" {:error, :math_error, details} -> "Math error: #{details}" {:error, :invalid_input, details} -> "Input error: #{details}" {:error, :unexpected, details} -> "Unexpected error: #{details}" end end end
Test it in IEx:
iex> ErrorHandling.execute_operation(:divide, [10, 2]) |> ErrorHandling.process_result() "Result: 5.0" iex> ErrorHandling.execute_operation(:divide, [10, 0]) |> ErrorHandling.process_result() "Input error: Invalid input for divide" iex> ErrorHandling.execute_operation(:sqrt, [-4]) |> ErrorHandling.process_result() "Input error: Invalid input for sqrt" iex> ErrorHandling.execute_operation(:unknown, []) |> ErrorHandling.process_result() "Input error: Invalid input for unknown"
Monadic Result (Either/Result Pattern)
defmodule Result do def ok(value), do: {:ok, value} def error(reason), do: {:error, reason} def map({:ok, value}, fun), do: {:ok, fun.(value)} def map({:error, _} = error, _fun), do: error def and_then({:ok, value}, fun), do: fun.(value) def and_then({:error, _} = error, _fun), do: error def map_error({:ok, _} = ok, _fun), do: ok def map_error({:error, reason}, fun), do: {:error, fun.(reason)} def unwrap({:ok, value}), do: value def unwrap({:error, reason}), do: raise(reason) def unwrap_or({:ok, value}, _default), do: value def unwrap_or({:error, _}, default), do: default end defmodule UserValidator do def validate_user(user) do Result.ok(user) |> validate_name() |> validate_age() |> validate_email() end defp validate_name({:ok, user}) do if is_binary(user[:name]) and String.length(user[:name]) > 0 do {:ok, user} else {:error, "Invalid name"} end end defp validate_name(error), do: error defp validate_age({:ok, user}) do if is_integer(user[:age]) and user[:age] >= 18 do {:ok, user} else {:error, "Age must be 18 or older"} end end defp validate_age(error), do: error defp validate_email({:ok, user}) do if is_binary(user[:email]) and String.contains?(user[:email], "@") do {:ok, user} else {:error, "Invalid email"} end end defp validate_email(error), do: error # Using the Result module def validate_user_monad(user) do Result.ok(user) |> Result.and_then(&validate_name_monad/1) |> Result.and_then(&validate_age_monad/1) |> Result.and_then(&validate_email_monad/1) end defp validate_name_monad(user) do if is_binary(user[:name]) and String.length(user[:name]) > 0 do Result.ok(user) else Result.error("Invalid name") end end defp validate_age_monad(user) do if is_integer(user[:age]) and user[:age] >= 18 do Result.ok(user) else Result.error("Age must be 18 or older") end end defp validate_email_monad(user) do if is_binary(user[:email]) and String.contains?(user[:email], "@") do Result.ok(user) else Result.error("Invalid email") end end end
Test it in IEx:
iex> valid_user = %{name: "Alice", age: 25, email: "alice@example.com"} iex> UserValidator.validate_user_monad(valid_user) {:ok, %{name: "Alice", age: 25, email: "alice@example.com"}} iex> invalid_name = %{name: "", age: 25, email: "alice@example.com"} iex> UserValidator.validate_user_monad(invalid_name) {:error, "Invalid name"} iex> underage = %{name: "Bob", age: 16, email: "bob@example.com"} %{name: "Bob", age: 16, email: "bob@example.com"} iex> UserValidator.validate_user_monad(underage) {:error, "Age must be 18 or older"} iex> invalid_email = %{name: "Charlie", age: 30, email: "invalid-email"} %{name: "Charlie", age: 30, email: "invalid-email"} iex> UserValidator.validate_user_monad(invalid_email) {:error, "Invalid email"}
Railway Oriented Programming
A popular functional pattern for handling success and error flows.
defmodule Railway do def bind(input, fun) do case input do {:ok, value} -> fun.(value) {:error, _} = error -> error end end def map(input, fun) do case input do {:ok, value} -> {:ok, fun.(value)} {:error, _} = error -> error end end def success(value), do: {:ok, value} def failure(error), do: {:error, error} # Practical examples def process_order(order) do success(order) |> bind(&validate_order/1) |> bind(&calculate_total/1) |> bind(&apply_discount/1) |> bind(&finalize_order/1) end defp validate_order(order) do cond do is_nil(order[:items]) || order[:items] == [] -> failure("Order has no items") is_nil(order[:customer_id]) -> failure("Customer ID is missing") true -> success(order) end end defp calculate_total(order) do total = Enum.reduce(order[:items], 0, fn item, acc -> acc + item.price * item.quantity end) success(Map.put(order, :total, total)) end defp apply_discount(order) do discount_factor = cond do Map.get(order, :total, 0) > 1000 -> 0.9 # 10% discount Map.get(order, :total, 0) > 500 -> 0.95 # 5% discount true -> 1.0 # No discount end final_total = order.total * discount_factor success(Map.put(order, :final_total, final_total)) end defp finalize_order(order) do # Simulating final processing success(%{order_id: "ORD-#{:rand.uniform(1000)}", customer_id: order[:customer_id], amount: order[:final_total]}) end end
Test it in IEx:
iex> valid_order = %{ customer_id: "USR-123", items: [ %{name: "Product A", price: 100, quantity: 2}, %{name: "Product B", price: 50, quantity: 1} ] } iex> Railway.process_order(valid_order) {:ok, %{order_id: "ORD-456", customer_id: "USR-123", amount: 237.5}} iex> empty_order = %{customer_id: "USR-123", items: []} %{items: [], customer_id: "USR-123"} iex> Railway.process_order(empty_order) {:error, "Order has no items"} iex> missing_customer = %{items: [%{name: "Product A", price: 100, quantity: 1}]} %{items: [%{name: "Product A", price: 100, quantity: 1}]} iex> Railway.process_order(missing_customer) {:error, "Customer ID is missing"}
State Machines in Elixir
Elixir is excellent for implementing state machines due to its pattern matching support.
defmodule DocumentWorkflow do # Defining the document struct defstruct [:id, :content, :status, :reviews, :approvals, history: []] # Functions to create and manage documents def new(id, content) do %__MODULE__{ id: id, content: content, status: :draft, reviews: [], approvals: [], history: [{:created, DateTime.utc_now()}] } end # State transitions def submit_for_review(%__MODULE__{status: :draft} = doc) do %{doc | status: :under_review, history: [{:submitted, DateTime.utc_now()} | doc.history] } end def submit_for_review(doc), do: {:error, "Only draft documents can be submitted for review"} def add_review(%__MODULE__{status: :under_review} = doc, reviewer, comments) do review = %{reviewer: reviewer, comments: comments, date: DateTime.utc_now()} %{doc | reviews: [review | doc.reviews], history: [{:reviewed, reviewer, DateTime.utc_now()} | doc.history] } end def add_review(_doc, _reviewer, _comments), do: {:error, "Document is not under review"} def approve(%__MODULE__{status: :under_review} = doc, approver) do case enough_reviews?(doc) do true -> %{doc | status: :approved, approvals: [%{approver: approver, date: DateTime.utc_now()} | doc.approvals], history: [{:approved, approver, DateTime.utc_now()} | doc.history] } false -> {:error, "Document needs at least 2 reviews before approval"} end end def approve(_doc, _approver), do: {:error, "Only documents under review can be approved"} def publish(%__MODULE__{status: :approved} = doc) do %{doc | status: :published, history: [{:published, DateTime.utc_now()} | doc.history] } end def publish(_doc), do: {:error, "Only approved documents can be published"} def reject(%__MODULE__{status: status} = doc, rejector, reason) when status in [:under_review, :approved] do %{doc | status: :rejected, history: [{:rejected, rejector, reason, DateTime.utc_now()} | doc.history] } end def reject(_doc, _rejector, _reason), do: {:error, "Document cannot be rejected in this state"} # Helper functions defp enough_reviews?(doc), do: length(doc.reviews) >= 2 # History visualization def print_history(%__MODULE__{history: history}) do history |> Enum.reverse() |> Enum.map(fn {:created, date} -> "Created on #{format_date(date)}" {:submitted, date} -> "Submitted for review on #{format_date(date)}" {:reviewed, reviewer, date} -> "Reviewed by #{reviewer} on #{format_date(date)}" {:approved, approver, date} -> "Approved by #{approver} on #{format_date(date)}" {:rejected, rejector, reason, date} -> "Rejected by #{rejector} on #{format_date(date)}: #{reason}" {:published, date} -> "Published on #{format_date(date)}" end) |> Enum.join("\n") end defp format_date(datetime), do: Calendar.strftime(datetime, "%d/%m/%Y %H:%M") end
Test it in IEx:
iex> doc = DocumentWorkflow.new("DOC-123", "Document content") iex> doc = DocumentWorkflow.submit_for_review(doc) iex> doc = DocumentWorkflow.add_review(doc, "Alice", "Good work") iex> doc = DocumentWorkflow.add_review(doc, "Bob", "Needs minor adjustments") iex> doc = DocumentWorkflow.approve(doc, "Carol") iex> doc = DocumentWorkflow.publish(doc) iex> DocumentWorkflow.print_history(doc)
Best Practices
Favoring Composition over Complexity
Instead of creating deeply nested control structures, prefer to break code into smaller functions that can be composed:
# Avoid this def complex_process(data) do with {:ok, validated} <- validate(data), {:ok, processed} <- case validated do %{type: :special} when is_map(validated.content) -> cond do Map.has_key?(validated.content, :priority) and validated.content.priority > 5 -> process_high_priority(validated) true -> process_special(validated) end _ -> process_normal(validated) end do finalize(processed) end end # Prefer this def complex_process(data) do with {:ok, validated} <- validate(data), {:ok, processed} <- process_by_type(validated), {:ok, result} <- finalize(processed) do {:ok, result} end end defp process_by_type(%{type: :special} = data) do if is_high_priority?(data) do process_high_priority(data) else process_special(data) end end defp process_by_type(data), do: process_normal(data) defp is_high_priority?(%{content: content}) do is_map(content) and Map.has_key?(content, :priority) and content.priority > 5 end
Consistent Error Propagation
Establish an error handling convention and follow it consistently:
# Defining an error utilities module defmodule ErrorUtil do def to_error_tuple(error, context) do {:error, %{error: error, context: context}} end def add_context({:error, details}, context) when is_map(details) do {:error, Map.put(details, :context, [context | Map.get(details, :context, [])])} end def add_context({:error, reason}, context) do {:error, %{error: reason, context: [context]}} end def add_context(other, _context), do: other def format_error({:error, %{error: error, context: contexts}}) do context_str = Enum.join(contexts, " -> ") "Error: #{error}, Context: #{context_str}" end def format_error({:error, reason}), do: "Error: #{reason}" def format_error(_), do: "Unknown error" end # Example usage defmodule UserService do def create_user(params) do with {:ok, validated} <- validate_user(params), {:ok, user} <- save_user(validated), {:ok, _} <- notify_created(user) do {:ok, user} else error -> ErrorUtil.add_context(error, "create_user") end end defp validate_user(params) do cond do is_nil(params[:email]) -> {:error, "email is required"} !String.contains?(params[:email], "@") -> {:error, "invalid email"} true -> {:ok, params} end end defp save_user(user) do # Simulating database save if String.ends_with?(user[:email], "example.com") do {:ok, Map.put(user, :id, "USR-#{:rand.uniform(1000)}")} else {:error, "email domain not allowed"} end end defp notify_created(user) do # Simulating notification if :rand.uniform(10) > 2 do {:ok, "notification-sent"} else {:error, "failed to send notification"} end end end
Test it in IEx:
iex> params = %{name: "Alice", email: "alice@example.com"} iex> case UserService.create_user(params) do {:ok, user} -> "User created: #{user.id}" error -> ErrorUtil.format_error(error) end iex> params = %{name: "Bob"} iex> case UserService.create_user(params) do {:ok, user} -> "User created: #{user.id}" error -> ErrorUtil.format_error(error) end iex> params = %{name: "Charlie", email: "charlie@gmail.com"} iex> case UserService.create_user(params) do {:ok, user} -> "User created: #{user.id}" error -> ErrorUtil.format_error(error) end
Avoiding Duplicate Conditions
# Avoid this def process_payment(payment) do cond do payment.amount <= 0 -> {:error, "Amount must be positive"} payment.currency not in ["USD", "EUR"] -> {:error, "Currency not supported"} payment.amount > 1000 and is_nil(payment.authorization) -> {:error, "Large payments need authorization"} true -> execute_payment(payment) end end # Prefer this def process_payment(payment) do with :ok <- validate_amount(payment.amount), :ok <- validate_currency(payment.currency), :ok <- validate_authorization(payment) do execute_payment(payment) end end defp validate_amount(amount) when amount <= 0, do: {:error, "Amount must be positive"} defp validate_amount(_), do: :ok defp validate_currency(currency) when currency in ["USD", "EUR"], do: :ok defp validate_currency(_), do: {:error, "Currency not supported"} defp validate_authorization(%{amount: amount, authorization: nil}) when amount > 1000 do {:error, "Large payments need authorization"} end defp validate_authorization(_), do: :ok
Conclusion
Advanced control structures in Elixir allow you to create complex and robust flows in an elegant and functional way. By combining pattern matching, guards, case
, cond
, and with
expressions, we can build code that is both expressive and error-resistant.
In this article, we explored:
- How to combine different control structures for complex cases
- Functional patterns for flow control, like function composition and monads
- Robust error handling strategies
- Railway Oriented Programming for success/error flow management
- Implementing state machines in Elixir
- Best practices for keeping code clean and maintainable
The key to using advanced control structures in Elixir is understanding when each approach is most suitable and how to combine them in a way that results in clean, expressive, and maintainable code.
Tip: When dealing with complex flows, ask yourself: "Can I break this down into smaller, more focused functions?" Composing simple functions often leads to clearer code than deeply nested control structures.
Further Reading
Next Steps
In the upcoming article, we'll explore Anonymous Functions:
Anonymous Functions
- Creating and using anonymous functions
- Understanding function captures with the
&
operator - Closures and variable capture in anonymous functions
- Passing anonymous functions to higher-order functions
- Common patterns with anonymous functions in collection operations
Functions are at the heart of functional programming in Elixir. While we've used various functions throughout this series, the next article will dive deep into anonymous functions – compact, flexible function definitions that can be assigned to variables and passed to other functions. You'll learn how these building blocks enable cleaner code, more effective abstractions, and enable many of the functional patterns we've touched on in this article.
Top comments (0)