Pattern Matching Production

Building robust business logic in Elixir? This guide teaches production pattern matching techniques, progressing from basic pattern matching to complex validation, guard clauses, and error handling patterns for financial systems.

Why Pattern Matching Matters

Pattern matching is Elixir’s primary tool for:

  • Business rule enforcement - Multi-clause functions encoding complex logic
  • Data validation - Structural pattern matching on input data
  • Error handling - Matching success/failure tuples for control flow
  • State machine implementation - Pattern matching on state transitions
  • Runtime constraint checking - Guard clauses for value-based validation

Unlike other languages where pattern matching is optional, Elixir makes it the foundation of control flow. Every function parameter, case statement, and with block uses pattern matching.

Our approach: Start with basic pattern matching, understand limitations without guards, then add guards and complex patterns for production validation.

Basic Pattern Matching Review

Function Clause Pattern Matching

Elixir matches function clauses top-to-bottom until first match:

# Simple pattern matching on values
defmodule Calculator do
  def operation(:add, a, b), do: a + b
                                             # => Matches when first arg is :add
                                             # => Returns: a + b

  def operation(:subtract, a, b), do: a - b
                                             # => Matches when first arg is :subtract

  def operation(:multiply, a, b), do: a * b
                                             # => Matches when first arg is :multiply

  def operation(:divide, a, 0) do
    {:error, :division_by_zero}              # => Matches divide with zero
                                             # => Returns: error tuple
  end

  def operation(:divide, a, b), do: a / b
                                             # => Matches divide with non-zero b
end

Usage:

Calculator.operation(:add, 10, 5)            # => Returns: 15
Calculator.operation(:subtract, 10, 5)       # => Returns: 5
Calculator.operation(:divide, 10, 0)         # => Returns: {:error, :division_by_zero}
Calculator.operation(:divide, 10, 5)         # => Returns: 2.0

Data Structure Pattern Matching

Match on maps, lists, and tuples:

# Pattern matching on maps
defmodule User do
  def greet(%{name: name, role: :admin}) do
    "Hello Admin #{name}!"                   # => Matches admin users
                                             # => name: Extracted from map
  end

  def greet(%{name: name, role: :user}) do
    "Hello #{name}!"                         # => Matches regular users
  end

  def greet(%{name: name}) do
    "Hello #{name}!"                         # => Matches any map with :name key
                                             # => Fallback when :role missing
  end
end

Usage:

User.greet(%{name: "Alice", role: :admin})   # => "Hello Admin Alice!"
User.greet(%{name: "Bob", role: :user})      # => "Hello Bob!"
User.greet(%{name: "Charlie"})               # => "Hello Charlie!"

List Pattern Matching

Match on list structure:

# Pattern matching on lists
defmodule ListProcessor do
  def process([]), do: :empty
                                             # => Empty list
                                             # => Returns: :empty

  def process([head | tail]) do
    {head, tail}                             # => Non-empty list
                                             # => head: First element
                                             # => tail: Remaining list
                                             # => Returns: {head, tail}
  end
end

Usage:

ListProcessor.process([])                    # => :empty
ListProcessor.process([1, 2, 3])             # => {1, [2, 3]}
ListProcessor.process([42])                  # => {42, []}

Limitations Without Guards

Pattern matching alone cannot check:

  1. Value ranges - Cannot match “x > 0” or “amount < 1000”
  2. Type checking - Cannot distinguish integer vs float
  3. Runtime conditions - Cannot check “is_valid_email?(email)”
  4. Complex predicates - Cannot match “divisible by 3”

Example problem:

# Want to validate positive numbers
defmodule Account do
  def deposit(amount) do
    # => Can't pattern match "amount > 0"
    # => Need guard clause
    if amount > 0 do
      {:ok, amount}
    else
      {:error, :invalid_amount}
    end
  end
end

Guard Clauses - Runtime Constraints

Guards add runtime checks to pattern matching:

# Guard clauses for value validation
defmodule Account do
  def deposit(amount) when amount > 0 do
    {:ok, amount}                            # => Matches when amount > 0
                                             # => Returns: {:ok, amount}
  end

  def deposit(_amount) do
    {:error, :invalid_amount}                # => Matches all other cases
                                             # => Returns: error tuple
  end
end

Usage:

Account.deposit(100)                         # => {:ok, 100}
Account.deposit(0)                           # => {:error, :invalid_amount}
Account.deposit(-50)                         # => {:error, :invalid_amount}

Allowed Guard Expressions

Guards support limited subset of operations for safety:

Comparison: ==, !=, <, >, <=, >= Boolean: and, or, not Arithmetic: +, -, *, / Type checks: is_integer/1, is_float/1, is_binary/1, is_map/1, is_list/1, is_atom/1 Other: length/1, elem/2, div/2, rem/2

NOT allowed in guards:

  • Custom functions (except whitelisted)
  • Pattern matching with =
  • case, cond, if
  • Anonymous functions
# Valid guards
def foo(x) when is_integer(x) and x > 0, do: x
def bar(x) when rem(x, 2) == 0, do: :even
def baz(x) when is_binary(x) and byte_size(x) > 5, do: :long

# Invalid guards
def invalid(x) when String.length(x) > 5, do: x  # => String.length not allowed
def invalid2(x) when my_check(x), do: x          # => Custom function not allowed

Production Pattern 1: Zakat Eligibility Checking

Implement Islamic wealth tax (Zakat) eligibility using pattern matching and guards:

Zakat rules:

  • Nisab: Minimum wealth threshold (85g gold equivalent)
  • Haul: Assets held for 1 lunar year
  • Zakatable wealth: Total assets - total debts
  • Rate: 2.5% on zakatable wealth if above nisab
# Zakat eligibility calculator with pattern matching
defmodule ZakatCalculator do
  @nisab_amount 85_000_000                   # => Nisab: 85 million (85g gold @ 1M/g)
                                             # => Indonesian Rupiah

  # Wealth structure
  defstruct [
    :cash,                                   # => Cash holdings
    :gold,                                   # => Gold value
    :investments,                            # => Investment value
    :debts,                                  # => Total debts
    :held_for_year                           # => Boolean: Held for haul?
  ]

  # Main eligibility function - pattern match on haul first
  def calculate_zakat(%__MODULE__{held_for_year: false}) do
    {:error, :haul_not_met}                  # => Assets not held for 1 year
                                             # => No zakat due
  end

  def calculate_zakat(%__MODULE__{
        cash: cash,
        gold: gold,
        investments: investments,
        debts: debts,
        held_for_year: true
      }) when is_integer(cash) and is_integer(gold) and
              is_integer(investments) and is_integer(debts) and
              cash >= 0 and gold >= 0 and investments >= 0 and debts >= 0 do
    # => All guards passed:
    # => - All values are integers
    # => - All values are non-negative
    # => - Haul requirement met

    zakatable_wealth = cash + gold + investments - debts
                                             # => Calculate net zakatable wealth

    calculate_amount(zakatable_wealth)
                                             # => Delegate to amount calculation
  end

  def calculate_zakat(_invalid_wealth) do
    {:error, :invalid_wealth_data}           # => Invalid data structure
                                             # => Returns: error tuple
  end

  # Amount calculation with guards on zakatable wealth
  defp calculate_amount(zakatable_wealth) when zakatable_wealth >= @nisab_amount do
    zakat_amount = div(zakatable_wealth * 25, 1000)
                                             # => 2.5% = 25/1000
                                             # => Use integer division
                                             # => zakat_amount in same currency

    {:ok, %{
      zakatable_wealth: zakatable_wealth,
      nisab: @nisab_amount,
      zakat_due: zakat_amount,
      rate: "2.5%"
    }}
                                             # => Returns: zakat details
  end

  defp calculate_amount(zakatable_wealth) when zakatable_wealth < @nisab_amount do
    {:ok, %{
      zakatable_wealth: zakatable_wealth,
      nisab: @nisab_amount,
      zakat_due: 0,
      reason: :below_nisab
    }}
                                             # => Below nisab, no zakat due
  end

  defp calculate_amount(_zakatable_wealth) when _zakatable_wealth < 0 do
    {:error, :negative_wealth}               # => Net wealth negative (debts > assets)
  end
end

Usage:

# Case 1: Above nisab, haul met
wealth = %ZakatCalculator{
  cash: 50_000_000,                          # => 50 million cash
  gold: 40_000_000,                          # => 40 million gold
  investments: 30_000_000,                   # => 30 million investments
  debts: 10_000_000,                         # => 10 million debts
  held_for_year: true                        # => Held for 1 year
}
# => Total assets: 120M
# => Zakatable wealth: 120M - 10M = 110M
# => Above nisab (85M)

ZakatCalculator.calculate_zakat(wealth)
# => {:ok, %{
# =>   zakatable_wealth: 110_000_000,
# =>   nisab: 85_000_000,
# =>   zakat_due: 2_750_000,              # => 2.5% of 110M
# =>   rate: "2.5%"
# => }}

# Case 2: Below nisab
wealth2 = %ZakatCalculator{
  cash: 50_000_000,
  gold: 20_000_000,
  investments: 10_000_000,
  debts: 5_000_000,
  held_for_year: true
}
# => Zakatable wealth: 75M (below nisab 85M)

ZakatCalculator.calculate_zakat(wealth2)
# => {:ok, %{
# =>   zakatable_wealth: 75_000_000,
# =>   nisab: 85_000_000,
# =>   zakat_due: 0,
# =>   reason: :below_nisab
# => }}

# Case 3: Haul not met
wealth3 = %ZakatCalculator{
  cash: 100_000_000,
  gold: 50_000_000,
  investments: 30_000_000,
  debts: 10_000_000,
  held_for_year: false                       # => Not held for 1 year
}

ZakatCalculator.calculate_zakat(wealth3)
# => {:error, :haul_not_met}

# Case 4: Invalid data (negative values)
wealth4 = %ZakatCalculator{
  cash: -10_000_000,                         # => Invalid: negative cash
  gold: 50_000_000,
  investments: 30_000_000,
  debts: 5_000_000,
  held_for_year: true
}

ZakatCalculator.calculate_zakat(wealth4)
# => {:error, :invalid_wealth_data}          # => Guard fails on cash >= 0

Production Pattern 2: with Pattern for Complex Validation

The with construct chains pattern matches for multi-step validation:

# Murabaha contract validation with multi-step checks
defmodule MurabahaContract do
  defstruct [
    :contract_id,
    :customer_id,
    :asset_cost,
    :profit_margin,                          # => Percentage
    :payment_term_months,
    :customer_credit_score
  ]

  # Create contract with validation pipeline
  def create_contract(params) do
    with {:ok, validated_params} <- validate_params(params),
         {:ok, credit_check} <- check_credit(validated_params.customer_id),
         {:ok, shariah_compliance} <- check_shariah_compliance(validated_params),
         {:ok, contract} <- build_contract(validated_params) do
      # => All validations passed
      {:ok, contract}                        # => Returns: created contract
    else
      {:error, reason} ->
        {:error, reason}                     # => Returns: first error encountered
    end
  end

  # Step 1: Parameter validation with guards
  defp validate_params(%{
         customer_id: customer_id,
         asset_cost: asset_cost,
         profit_margin: profit_margin,
         payment_term_months: payment_term_months
       })
       when is_binary(customer_id) and byte_size(customer_id) > 0 and
            is_integer(asset_cost) and asset_cost > 0 and
            is_number(profit_margin) and profit_margin > 0 and profit_margin <= 30 and
            is_integer(payment_term_months) and payment_term_months > 0 and payment_term_months <= 60 do
    # => All guards passed:
    # => - customer_id is non-empty string
    # => - asset_cost is positive integer
    # => - profit_margin is 0-30%
    # => - payment_term is 1-60 months

    {:ok, %{
      customer_id: customer_id,
      asset_cost: asset_cost,
      profit_margin: profit_margin,
      payment_term_months: payment_term_months
    }}
  end

  defp validate_params(_invalid) do
    {:error, :invalid_parameters}            # => Guard failed or missing keys
  end

  # Step 2: Credit check with pattern matching
  defp check_credit(customer_id) do
    # Simulate credit score lookup
    credit_score = fetch_credit_score(customer_id)

    case credit_score do
      score when score >= 650 ->
        {:ok, %{score: score, approved: true}}
                                             # => Credit score acceptable
                                             # => Minimum 650 for Murabaha

      score when score < 650 ->
        {:error, {:credit_check_failed, score}}
                                             # => Credit score too low

      nil ->
        {:error, :customer_not_found}        # => Customer doesn't exist
    end
  end

  # Step 3: Shariah compliance check
  defp check_shariah_compliance(%{profit_margin: margin}) when margin <= 15 do
    {:ok, %{compliant: true, category: :low_margin}}
                                             # => Profit margin <= 15% (preferred)
  end

  defp check_shariah_compliance(%{profit_margin: margin}) when margin > 15 and margin <= 30 do
    {:ok, %{compliant: true, category: :high_margin, requires_justification: true}}
                                             # => Profit margin 15-30% (allowed but requires justification)
  end

  defp check_shariah_compliance(%{profit_margin: margin}) when margin > 30 do
    {:error, :excessive_profit_margin}       # => Profit margin > 30% (not compliant)
  end

  # Step 4: Build contract
  defp build_contract(params) do
    contract = %__MODULE__{
      contract_id: generate_contract_id(),
      customer_id: params.customer_id,
      asset_cost: params.asset_cost,
      profit_margin: params.profit_margin,
      payment_term_months: params.payment_term_months
    }

    {:ok, contract}
  end

  # Helper functions
  defp fetch_credit_score(customer_id) do
    # Simulate database lookup
    case customer_id do
      "customer-good" -> 720
      "customer-fair" -> 640
      "customer-poor" -> 500
      _ -> nil
    end
  end

  defp generate_contract_id do
    "MURABAHA-" <> :crypto.strong_rand_bytes(8) |> Base.encode16()
  end
end

Usage:

# Case 1: Valid contract
params = %{
  customer_id: "customer-good",
  asset_cost: 100_000_000,                   # => 100 million asset
  profit_margin: 12.5,                       # => 12.5% profit
  payment_term_months: 24                    # => 24 month term
}

MurabahaContract.create_contract(params)
# => {:ok, %MurabahaContract{
# =>   contract_id: "MURABAHA-...",
# =>   customer_id: "customer-good",
# =>   asset_cost: 100_000_000,
# =>   profit_margin: 12.5,
# =>   payment_term_months: 24,
# =>   customer_credit_score: nil
# => }}

# Case 2: Credit check failure
params2 = %{
  customer_id: "customer-poor",              # => Credit score 500
  asset_cost: 50_000_000,
  profit_margin: 10,
  payment_term_months: 12
}

MurabahaContract.create_contract(params2)
# => {:error, {:credit_check_failed, 500}}
# => Fails at credit check step

# Case 3: Excessive profit margin
params3 = %{
  customer_id: "customer-good",
  asset_cost: 80_000_000,
  profit_margin: 35,                         # => 35% exceeds 30% limit
  payment_term_months: 18
}

MurabahaContract.create_contract(params3)
# => {:error, :excessive_profit_margin}
# => Fails at Shariah compliance check

# Case 4: Invalid parameters
params4 = %{
  customer_id: "",                           # => Empty string
  asset_cost: 100_000_000,
  profit_margin: 10,
  payment_term_months: 24
}

MurabahaContract.create_contract(params4)
# => {:error, :invalid_parameters}
# => Fails at parameter validation (guard fails on empty customer_id)

Production Pattern 3: case/cond for Control Flow

case - Pattern Matching Multiple Outcomes

Use case when matching on specific values:

# Payment processing with case pattern matching
defmodule PaymentProcessor do
  def process_payment(payment_method, amount) when amount > 0 do
    case payment_method do
      {:credit_card, card_number, cvv} ->
        process_credit_card(card_number, cvv, amount)
                                             # => Matches credit card tuple
                                             # => Extracts card_number and cvv

      {:bank_transfer, account_number} ->
        process_bank_transfer(account_number, amount)
                                             # => Matches bank transfer tuple

      {:digital_wallet, wallet_id} ->
        process_digital_wallet(wallet_id, amount)
                                             # => Matches digital wallet tuple

      _ ->
        {:error, :unsupported_payment_method}
                                             # => Catch-all for unknown methods
    end
  end

  def process_payment(_payment_method, _amount) do
    {:error, :invalid_amount}                # => Amount <= 0
  end

  defp process_credit_card(card_number, cvv, amount) do
    # Simulate payment gateway call
    {:ok, %{method: :credit_card, amount: amount, status: :processed}}
  end

  defp process_bank_transfer(account_number, amount) do
    {:ok, %{method: :bank_transfer, amount: amount, status: :pending}}
  end

  defp process_digital_wallet(wallet_id, amount) do
    {:ok, %{method: :digital_wallet, amount: amount, status: :processed}}
  end
end

Usage:

PaymentProcessor.process_payment({:credit_card, "4111111111111111", "123"}, 50_000)
# => {:ok, %{method: :credit_card, amount: 50_000, status: :processed}}

PaymentProcessor.process_payment({:bank_transfer, "1234567890"}, 100_000)
# => {:ok, %{method: :bank_transfer, amount: 100_000, status: :pending}}

PaymentProcessor.process_payment({:unknown_method}, 25_000)
# => {:error, :unsupported_payment_method}

PaymentProcessor.process_payment({:credit_card, "4111111111111111", "123"}, -100)
# => {:error, :invalid_amount}

cond - Multiple Conditions

Use cond when checking multiple boolean conditions:

# Investment risk categorization with cond
defmodule InvestmentRisk do
  def categorize(amount, duration_months, volatility_index) do
    cond do
      amount >= 1_000_000_000 and duration_months < 12 ->
        {:high_risk, "Large short-term investment"}
                                             # => Amount >= 1B, duration < 1 year
                                             # => High risk category

      volatility_index > 50 ->
        {:high_risk, "High market volatility"}
                                             # => Volatility index > 50
                                             # => High risk regardless of amount

      amount >= 500_000_000 and duration_months >= 12 and volatility_index < 30 ->
        {:medium_risk, "Large long-term stable investment"}
                                             # => Amount >= 500M, duration >= 1 year, stable market
                                             # => Medium risk

      amount < 100_000_000 and duration_months >= 24 ->
        {:low_risk, "Small long-term investment"}
                                             # => Amount < 100M, duration >= 2 years
                                             # => Low risk

      true ->
        {:medium_risk, "Standard investment profile"}
                                             # => Default case
                                             # => Medium risk
    end
  end
end

Usage:

InvestmentRisk.categorize(1_500_000_000, 6, 25)
# => {:high_risk, "Large short-term investment"}
# => 1.5B investment, 6 months duration

InvestmentRisk.categorize(300_000_000, 18, 55)
# => {:high_risk, "High market volatility"}
# => Volatility 55 triggers high risk

InvestmentRisk.categorize(600_000_000, 24, 20)
# => {:medium_risk, "Large long-term stable investment"}
# => 600M, 24 months, volatility 20

InvestmentRisk.categorize(50_000_000, 36, 25)
# => {:low_risk, "Small long-term investment"}
# => 50M, 36 months

InvestmentRisk.categorize(200_000_000, 18, 35)
# => {:medium_risk, "Standard investment profile"}
# => Doesn't match any specific category

Production Pattern 4: Error Handling with Pattern Matching

Result Tuple Pattern

Use {:ok, value} and {:error, reason} consistently:

# Database operations with error handling
defmodule ContractRepository do
  def find_contract(contract_id) when is_binary(contract_id) do
    # Simulate database lookup
    case db_query(contract_id) do
      nil ->
        {:error, :not_found}                 # => Contract doesn't exist

      contract ->
        {:ok, contract}                      # => Contract found
    end
  end

  def find_contract(_invalid_id) do
    {:error, :invalid_contract_id}           # => Invalid ID type
  end

  def update_contract(contract_id, updates) do
    with {:ok, contract} <- find_contract(contract_id),
         {:ok, validated_updates} <- validate_updates(updates),
         {:ok, updated_contract} <- apply_updates(contract, validated_updates) do
      {:ok, updated_contract}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  defp db_query(contract_id) do
    # Simulate database
    if contract_id == "contract-123" do
      %{id: "contract-123", amount: 100_000, status: :pending}
    else
      nil
    end
  end

  defp validate_updates(updates) when is_map(updates) do
    {:ok, updates}
  end

  defp validate_updates(_invalid) do
    {:error, :invalid_updates}
  end

  defp apply_updates(contract, updates) do
    {:ok, Map.merge(contract, updates)}
  end
end

Usage:

# Success case
ContractRepository.find_contract("contract-123")
# => {:ok, %{id: "contract-123", amount: 100_000, status: :pending}}

# Not found
ContractRepository.find_contract("contract-999")
# => {:error, :not_found}

# Invalid ID
ContractRepository.find_contract(123)
# => {:error, :invalid_contract_id}

# Update contract
ContractRepository.update_contract("contract-123", %{status: :approved})
# => {:ok, %{id: "contract-123", amount: 100_000, status: :approved}}

# Update non-existent contract
ContractRepository.update_contract("contract-999", %{status: :approved})
# => {:error, :not_found}

Best Practices

1. Order Clauses from Specific to General

# Good: Specific cases first
def process(0), do: :zero
def process(n) when n < 0, do: :negative
def process(n) when n > 0, do: :positive

# Bad: General case first shadows specific cases
def process(n), do: :any_number                # => Matches everything first!
def process(0), do: :zero                       # => Never reached

2. Use Guards for Value Constraints

# Good: Guards express constraints clearly
def withdraw(amount) when amount > 0 and amount <= 10_000_000 do
  {:ok, amount}
end

# Bad: if/else inside function
def withdraw(amount) do
  if amount > 0 and amount <= 10_000_000 do
    {:ok, amount}
  else
    {:error, :invalid_amount}
  end
end

3. Pattern Match on Success/Error Tuples

# Good: Pattern match in with
with {:ok, user} <- fetch_user(user_id),
     {:ok, account} <- fetch_account(user.account_id) do
  {:ok, {user, account}}
end

# Bad: Case nesting
case fetch_user(user_id) do
  {:ok, user} ->
    case fetch_account(user.account_id) do
      {:ok, account} -> {:ok, {user, account}}
      error -> error
    end
  error -> error
end

4. Use Structs for Domain Models

# Good: Struct with pattern matching
defmodule Contract do
  defstruct [:id, :amount, :status]

  def approve(%__MODULE__{status: :pending} = contract) do
    %{contract | status: :approved}
  end

  def approve(%__MODULE__{} = _contract) do
    {:error, :invalid_status}
  end
end

# Bad: Plain map without type safety
def approve(%{status: :pending} = contract) do
  %{contract | status: :approved}           # => No compile-time guarantee of structure
end

5. Combine Guards with and/or

# Good: Multiple guards with and
def eligible_for_zakat(wealth, held_months)
    when is_integer(wealth) and wealth >= 85_000_000 and
         is_integer(held_months) and held_months >= 12 do
  true
end

# Good: Alternative guards with multiple clauses
def valid_status?(status) when status == :pending, do: true
def valid_status?(status) when status == :approved, do: true
def valid_status?(status) when status == :rejected, do: true
def valid_status?(_), do: false

When to Use Each Pattern

Function Clauses with Guards:

  • Encoding business rules as separate function clauses
  • State machine transitions
  • Type-based dispatch

with:

  • Multi-step validation pipelines
  • Chaining operations that can fail
  • Clear error propagation

case:

  • Pattern matching on specific data structures
  • Handling multiple possible return values
  • Complex pattern matching in one place

cond:

  • Multiple boolean conditions
  • Priority-based logic (first true condition wins)
  • When guards not sufficient (need custom functions)

Next Steps

Completed: Pattern matching for production business logic

Continue learning:

Foundation knowledge:

Quick reference:


Summary: Pattern matching is Elixir’s foundation for business logic, validation, and error handling. Use function clauses with guards for business rules, with for validation pipelines, case for data structure matching, and cond for boolean conditions. Always pattern match on result tuples for clear error handling.

Last updated