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
endUsage:
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.0Data 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
endUsage:
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
endUsage:
ListProcessor.process([]) # => :empty
ListProcessor.process([1, 2, 3]) # => {1, [2, 3]}
ListProcessor.process([42]) # => {42, []}Limitations Without Guards
Pattern matching alone cannot check:
- Value ranges - Cannot match “x > 0” or “amount < 1000”
- Type checking - Cannot distinguish integer vs float
- Runtime conditions - Cannot check “is_valid_email?(email)”
- 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
endGuard 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
endUsage:
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 allowedProduction 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
endUsage:
# 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 >= 0Production 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
endUsage:
# 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
endUsage:
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
endUsage:
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 categoryProduction 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
endUsage:
# 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 reached2. 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
end3. 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
end4. 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
end5. 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: falseWhen 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:
- Structs Protocols - Struct design and protocol polymorphism
- Type Specifications - Typespecs and Dialyzer for compile-time checking
- Error Handling Resilience - Let it crash, supervision, circuit breakers
Foundation knowledge:
- GenServer Patterns - State management with pattern matching
Quick reference:
- Overview - All 36 In-the-Field guides
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.