“You’re either in or you’re out.” — Ocean’s Eleven (2001)

Run in Livebook

Why Traversal?

A Lens is a required focus: if the focus does not exist, that is an invariant violation.

A Prism is a Maybe focus: the branch either matches and yields a focus, or it does not.

A Lens and a Prism define a single focus, but sometimes we need multiple foci. That is a job for Traversal.

When we hear “traversal” we usually think “iterate a list” or “walk a tree.” That is not the right mental model for an optic traversal. A traversal does not describe how to walk the structure. It names multiple foci in the same structure.

The Problem

Let’s continue our system for processing transactions. A transaction can be a charge or a refund, and it can be paid by check or credit card.

What’s new is that each transaction now has an item with a price:

Transaction
├─ item
│  ├─ name
│  └─ price
└─ type
   ├─ Charge
   │  ├─ payment
   │  │  ├─ CreditCard
   │  │  │  └─ amount   ← cc_payment
   │  │  └─ Check
   │  │     └─ amount   ← check_payment
   │  └─ status
   │
   └─ Refund
      ├─ payment
      │  ├─ CreditCard
      │  │  └─ amount   ← cc_refund
      │  └─ Check
      │     └─ amount   ← check_refund
      └─ status

Building the Domain Model

First, let’s define our domain structures:

alias Funx.Optics.{Lens, Prism}
require Logger
use Funx.Monad.Maybe

defmodule CreditCard do
  defstruct [:name, :number, :expiry, :amount]
  alias Funx.Optics.Prism

  def amount_prism do
    Prism.path([{__MODULE__, :amount}])
  end
end

defmodule Check do
  defstruct [:name, :routing_number, :account_number, :amount]
  alias Funx.Optics.Prism

  def amount_prism do
    Prism.path([{__MODULE__, :amount}])
  end
end

defmodule Item do
  defstruct [:name, :price]
end

defmodule Charge do
  defstruct [:payment, :status]
  alias Funx.Optics.Prism

  def payment_prism do
    Prism.path([{__MODULE__, :payment}])
  end
end

defmodule Refund do
  defstruct [:payment, :status]
  alias Funx.Optics.Prism

  def payment_prism do
    Prism.path([{__MODULE__, :payment}])
  end
end

defmodule Transaction do
  defstruct [:item, :type]
  alias Funx.Optics.Prism

  def type_prism do
    Prism.path([{__MODULE__, :type}])
  end
end

Next, some transactions:

charge_cc =
  %Transaction{
    item: %Item{name: "Camera", price: 500},
    type: %Charge{
      payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
      status: :pending
    }
  }

invalid_charge_cc =
  %Transaction{
    item: %Item{name: "Camera", price: 500},
    type: %Charge{
      payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 400},
      status: :pending
    }
  }

charge_check =
  %Transaction{
    item: %Item{name: "Lens", price: 300},
    type: %Charge{
      payment: %Check{name: "Bob", routing_number: "111000025", account_number: "987654", amount: 300},
      status: :pending
    }
  }

refund_cc =
  %Transaction{
    item: %Item{name: "Tripod", price: 150},
    type: %Refund{
      payment: %CreditCard{name: "Carol", number: "4333", expiry: "10/27", amount: 150},
      status: :pending
    }
  }

refund_check =
  %Transaction{
    item: %Item{name: "Flash", price: 200},
    type: %Refund{
      payment: %Check{name: "Dave", routing_number: "222000025", account_number: "123456", amount: 200},
      status: :pending
    }
  }

transactions = [charge_cc, charge_check, refund_cc, refund_check]

Our domain requires that a transaction’s item price match its payment amount. We don’t need a single focus: we need both the item and the payment.

This is a boundary problem: we want to prevent an invalid transaction from being processed.

Process a Transaction

In Elixir, the usual way to do that is to protect the boundary in the function head, using pattern matching to ensure the required shape before any work happens.

process_basic = fn
  %Transaction{
    item: %Item{price: price, name: name},
    type: %{payment: %{amount: amount}} = type
  } = transaction
  when price == amount ->
    Logger.info("Processing $#{amount} for #{name}")
    %{transaction | type: %{type | status: :complete}}
end

This function extracts the price and amount, validates that they match in the guard, and completes the transaction:

process_basic.(charge_cc)

# [info] Processing $500 for Camera
#
# %Transaction{
#   item: %Item{name: "Camera", price: 500},
#   type: %Charge{
#     payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
#     status: :complete
#   }
# }

Here, the happy path succeeds.

And the invalid transaction raises:

process_basic.(invalid_charge_cc)

# ** (FunctionClauseError) no function clause matching in :erl_eval."

Putting the domain rule in the function head works, but there is no way to extract this domain logic to test and share.

Thinking Functionally

Let’s use a traversal:

alias Funx.Optics.{Lens, Traversal}

item_and_payment_trav =
  Traversal.combine([
    Lens.path([:item]),
    Lens.path([:type, :payment])
  ])

We can use Traversal.to_list/2 to get both values:

charge_cc |> Traversal.to_list(item_and_payment_trav)

# [
#   %Item{name: "Camera", price: 500},
#   %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500}
# ]

Here, we receive a list of foci: the Item and the CreditCard.

And we can extend our pipe to check the domain rule:

charge_cc
|> Traversal.to_list(item_and_payment_trav)
|> then(fn [item, payment] ->
  item.price == payment.amount
end)

# true

Here, the happy path is true.

invalid_charge_cc
|> Traversal.to_list(item_and_payment_trav)
|> then(fn [item, payment] ->
  item.price == payment.amount
end)

# false

And the unhappy path is false.

Maybe DSL

Let’s implement this logic in the Maybe DSL:

process_with_traversal = fn transaction ->
  maybe transaction, as: :raise do
    bind Traversal.to_list_maybe(item_and_payment_trav)
    guard fn [item, payment] -> item.price == payment.amount end
    tap fn [item, payment] ->
      Logger.info("Processing $#{payment.amount} for #{item.name}")
    end
    bind fn _val -> Lens.set(transaction, Lens.path([:type, :status]), :complete) end
  end
end

Here, we are using to_list_maybe/2 to lift the results of our traversal into the Maybe context. Next, we narrow the boundary with a guard implementing the domain rule. Then we log what we plan to do, and finally we update our transaction status to :complete.

Our happy path still works:

process_with_traversal.(charge_cc)

# [info] Processing $500 for Camera
#
# %Transaction{
#   item: %Item{name: "Camera", price: 500},
#   type: %Charge{
#     payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
#     status: :complete
#   }
# }

And our unhappy path continues to fail quickly:

process_with_traversal.(invalid_charge_cc)

# ** (RuntimeError) Nothing value encountered

Now our boundary rules are easy to update, test, and share, which becomes more important as complexity grows.

Implementing Sum Types

Let’s extend our boundary to the payment type. Not just a payment, but the specific payment branch, such as “refund credit card.”

Again, the idiomatic Elixir approach is to encode the boundary logic directly in the function head.

defmodule Guarded.TransactionProcessor do
  def cc_payment(
        %Transaction{
          item: %Item{price: item_price, name: name},
          type: %Charge{
            payment: %CreditCard{amount: payment_amount}
          } = charge
        } = transaction
      ) when item_price == payment_amount do
    Logger.info("Charge cc $#{payment_amount} for #{name}")

    %{
      transaction
      | type: %{charge | status: :complete}
    }
  end

  def check_payment(
        %Transaction{
          item: %Item{price: item_price, name: name},
          type: %Charge{
            payment: %Check{amount: payment_amount}
          } = charge
        } = transaction
      ) when item_price == payment_amount do
    Logger.info("Charge check $#{payment_amount} for #{name}")

    %{
      transaction
      | type: %{charge | status: :complete}
    }
  end

  def cc_refund(
        %Transaction{
          item: %Item{price: item_price, name: name},
          type: %Refund{
            payment: %CreditCard{amount: payment_amount}
          } = refund
        } = transaction
      ) when item_price == payment_amount do
    Logger.info("Refund cc $#{payment_amount} for #{name}")

    %{
      transaction
      | type: %{refund | status: :complete}
    }
  end

  def check_refund(
        %Transaction{
          item: %Item{price: item_price, name: name},
          type: %Refund{
            payment: %Check{amount: payment_amount}
          } = refund
        } = transaction
      ) when item_price == payment_amount do
    Logger.info("Refund check $#{payment_amount} for #{name}")

    %{
      transaction
      | type: %{refund | status: :complete}
    }
  end
end

There is nothing particularly wrong with this logic, but if we need it in other places we will need to copy and paste. In fact, we are copying and pasting our domain rule item_price == payment_amount four times in this module alone.

Our happy path works:

Guarded.TransactionProcessor.cc_payment(charge_cc)

# [info] Charge cc $500 for Camera
#
# %Transaction{
#   item: %Item{name: "Camera", price: 500},
#   type: %Charge{
#     payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
#     status: :complete
#   }
# }

The invalid charge still raises:

Guarded.TransactionProcessor.cc_payment(invalid_charge_cc)

# ** (FunctionClauseError) no function clause matching in Guarded.TransactionProcessor.cc_payment/1    

And now a payment mismatch also raises:

Guarded.TransactionProcessor.cc_payment(refund_check)

# ** (FunctionClauseError) no function clause matching in Guarded.TransactionProcessor.cc_payment/1    

Our function head is doing three things at once:

  • It selects the shape required by the operation.
  • It extracts the foci needed by the operation.
  • It enforces the domain rule that relates those foci.

Again, it works, but it is not a reusable boundary. If the same applicability rule matters in another workflow, the rule has to be re-expressed in a new function head. Worse, when the domain changes, we need to find and update all the rules in lockstep.

The Functional Way

First, let’s compose the traversals we care about:

defmodule Processor do
  alias Funx.Optics.{Lens, Prism, Traversal}

  def item_lens do
    Lens.path([:item])
  end

  def cc_payment_trav do
    Traversal.combine([
      Processor.item_lens,
      Prism.compose([
        Transaction.type_prism,
        Charge.payment_prism,
        Prism.struct(CreditCard)
      ])
    ])
  end

  def check_payment_trav do
    Traversal.combine([
      Processor.item_lens,
      Prism.compose([
        Transaction.type_prism,
        Charge.payment_prism,
        Prism.struct(Check)
      ])
    ])
  end

  def cc_refund_trav do
    Traversal.combine([
      Processor.item_lens,
      Prism.compose([
        Transaction.type_prism,
        Refund.payment_prism,
        Prism.struct(CreditCard)
      ])
    ])
  end

  def check_refund_trav do
    Traversal.combine([
      Processor.item_lens,
      Prism.compose([
        Transaction.type_prism,
        Refund.payment_prism,
        Prism.struct(Check)
      ])
    ])
  end

  def payment_status_lens do
    Lens.path([:type, :status])
  end
end

Here, we are no longer using a Lens for our payments. Instead we express the sum type with a Prism. The item must exist, and the payment branch might exist.

Domain validation

Let’s extract our guard:

defmodule PaymentMustMatchPrice do
  def run_maybe([item, payment], _opts, _env) do
    item.price == payment.amount
  end
end

Logging

And isolate our log:

defmodule LogTransaction do
  def run_maybe([item, payment], opts, _env) do
    prefix = Keyword.get(opts, :prefix)

    Logger.info("#{prefix} $#{payment.amount} for #{item.name}")
    :ok
  end
end

Completing the transaction

And our update logic as well:

defmodule CompleteTransaction do
  alias Funx.Optics.Lens

  def run_maybe(_foci, opts, _env) do
    transaction = Keyword.get(opts, :original)

    Lens.set(transaction, Processor.payment_status_lens, :complete)
  end
end

Declarative Processor

Now we can make a much more declarative processor:

defmodule Declarative.TransactionProcessor do
  alias Funx.Optics.Traversal

  def cc_payment(transaction) do
    maybe transaction, as: :raise do
      bind Traversal.to_list_maybe(Processor.cc_payment_trav)
      guard PaymentMustMatchPrice
      tap {LogTransaction, prefix: "Charging cc"}
      bind {CompleteTransaction, original: transaction}
    end
  end

  def check_payment(transaction) do
    maybe transaction, as: :raise do
      bind Traversal.to_list_maybe(Processor.check_payment_trav)
      guard PaymentMustMatchPrice
      tap {LogTransaction, prefix: "Charging check"}
      bind {CompleteTransaction, original: transaction}
    end
  end

  def cc_refund(transaction) do
    maybe transaction, as: :raise do
      bind Traversal.to_list_maybe(Processor.cc_refund_trav)
      guard PaymentMustMatchPrice
      tap {LogTransaction, prefix: "Refunding cc"}
      bind {CompleteTransaction, original: transaction}
    end
  end

  def check_refund(transaction) do
    maybe transaction, as: :raise do
      bind Traversal.to_list_maybe(Processor.check_refund_trav)
      guard PaymentMustMatchPrice
      tap {LogTransaction, prefix: "Refunding check"}
      bind {CompleteTransaction, original: transaction}
    end
  end
end

Here:

  • Traversal.to_list_maybe/2 lifts our traversal into the Maybe context, and enforces that either all foci exist together (Just) or one or more prisms are missing (Nothing).
  • guard/2 further reduces the boundary with our domain rule.
  • tap/2 calls the log effect, but does not fail the pipeline.
  • CompleteTransaction defines an action that may fail.

Again, our happy path succeeds:

Declarative.TransactionProcessor.cc_payment(charge_cc)

# [info] Charge cc $500 for Camera
#
# %Transaction{
#   item: %Item{name: "Camera", price: 500},
#   type: %Charge{
#     payment: %CreditCard{name: "Alice", number: "4111", expiry: "12/26", amount: 500},
#     status: :complete
#   }
# }

The invalid credit card charge raises:

Declarative.TransactionProcessor.cc_payment(refund_cc)

# ** (RuntimeError) Nothing value encountered

And so does the mismatch:

Declarative.TransactionProcessor.cc_payment(invalid_charge_cc)

# ** (RuntimeError) Nothing value encountered

This is not about replacing pattern matching. Pattern matching remains a strong tool. The difference is that the requirements for an operation can exist as values, which lets the code stay declarative: named, reusable, testable, and composable.

Resources

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.