How do we handle side effects in a pure functional system?

In Monads for functional programming, Philip Wadler’s answer wasn’t to avoid side effects, but to defer them. Instead of performing effects immediately, we construct a description using pure functions, and later run them within a protected boundary. This idea became the foundation of Haskell’s IO monad, but the pattern shows up in many real-world systems.

Object-oriented systems face the same puzzle, but instead of building descriptions, they mutate objects directly and hide the effect behind a transaction boundary.

Object-Relational Mapping

ORM tools like Hibernate or Entity Framework delay the database call, but instead of constructing a description of the effect, they mutate local objects first and then synchronize later within a controlled boundary such as SaveChanges().

Encapsulated Intent

public class MenuItem
{
    public string Name { get; private set; }
    public decimal Price { get; private set; }

    public void Rename(string newName)
    {
        if (string.IsNullOrWhiteSpace(newName))
            throw new ArgumentException("Name cannot be empty.");

        Name = newName;
    }

    public void UpdatePrice(decimal newPrice)
    {
        if (newPrice <= 0)
            throw new ArgumentException("Price must be greater than zero.");

        Price = newPrice;
    }
}

Each method mutates the object in memory with failures signaled by exceptions. For example:

item.UpdatePrice(-10m);

This throws immediately. Without the guard, the local object would hold a negative price, but a database constraint could reject it in SaveChanges().

SaveChanges: The Protected Boundary

using (var context = new AppDbContext())
{
    var item = context.MenuItems.Find(1);

    // Local changes and validation
    item.Rename("Breakfast Special");
    item.UpdatePrice(12.50m);

    // Protected boundary for the effect
    context.SaveChanges();
}

Here, SaveChanges() is the transaction boundary. The ORM notices which fields were mutated in memory and pushes those changes to the database. If a database constraint or connection failure occurs, the call short-circuits: the first error raises an exception and aborts the update.

In ORM, the change exists in memory until the transaction boundary.

Ecto

Ecto is closer to Wadler’s idea. A changeset is a description of what should happen, separating intent from execution. Unlike ORM methods that mutate objects directly, building a changeset never raises; it collects validation failures as error state.

Schema

defmodule MenuItem do
  use Ecto.Schema

  schema "menu_items" do
    field :name, :string
    field :price, :decimal
  end
end

Ecto.Schema defines a plain struct with fields.

Changeset

def changeset(%MenuItem{} = item, attrs) do
  item
  |> cast(attrs, [:name, :price])
  |> validate_required([:name, :price])
  |> validate_number(:price, greater_than: 0)
end

A changeset describes:

  • which fields may change
  • which are required
  • what rules must hold

Repo

changeset = MenuItem.changeset(item, attrs)

Repo.update(changeset)

Repo is the boundary where side effects occur. It interprets the changeset and carries out the update. Outcomes are returned explicitly as {:ok, result} or {:error, changeset}, keeping error handling in the pipeline rather than hidden behind exceptions.

Ecto applies Wadler’s pattern—describe first, run later in a protected boundary—to relational persistence.

However, Wadler’s descriptions are both safe and lazy. They remain inert until interpreted. Ecto’s changesets are safe but eager: validations are applied as soon as the changeset is built.

Funx

Funx’s Effect monad builds on Wadler’s idea, making it possible to describe any side effect ahead of time and run it later within a controlled boundary.

Let’s revisit our MenuItem example.

Struct

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

First, we need a struct to hold our MenuItem.

Validation

def ensure_name(%MenuItem{name: name}) do
  Either.lift_predicate(
    name,
    &String.trim(&1) != "",
    fn _ -> "name cannot be blank" end
  )
  |> Either.map_left(&ValidationError.new/1)
end

def ensure_price(%MenuItem{name: name, price: price}) do
  Either.lift_predicate(
    price,
    &(price >= 0),
    fn _ -> "#{name}: price must be non-negative" end
  )
  |> Either.map_left(&ValidationError.new/1)
end

Here we lift predicates into the Either monad, returning either the item or the reason it failed.

Validation Pipeline

def validate(item) do
  Either.validate(item, [
    &ensure_name/1,
    &ensure_price/1
  ])
end

This returns Right(item) if all validations pass, or Left(errors) if any fail.

Deferring Execution

def lazy_validation(item) do
  Effect.lift_either(fn -> validate(item) end)
end

Lifting into an Effect defers execution: nothing runs until explicitly executed at the boundary.

Composing

def update(item) do
  item
  |> lazy_validation()
  |> Effect.bind(&Repo.update/1)
end

We can compose our deferred validation with a hypothetical Repo.update/1 function.

Running the Effect

Finally, we run the effect in a protected boundary:

Effect.run(update(item))

This yields an Either:

  • Right(updated_item) if validation passes and the update succeeds
  • Left(error) if validation fails or the repo fails

Operations chained with bind/2 short-circuit on the first Left.

Takeaway

The real skill is recognizing patterns. They help you break down problems, focus on what matters, and apply ideas that carry across systems and tools. Once you learn to see them, that understanding stays with you. That’s what makes them worth learning.

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.