Why Effects? >

Test Hex.pm Documentation

An effectful programming framework for Elixir.

Bundled with a library of effects and components spanning state management, cooperative concurrency, streaming, efficient query execution, component architecture, and durable serialisable workflows:

                          Comp
                     (lazy computation,
                      evidence-passing,
                      scoped handlers)
                            
      
                                                      
 //Foundational       //Coroutines &               //Boundaries
 //Effects            //Concurrency                     
                                                      
                       Coroutine                
                                                         
 State, Reader,                      Port
 Writer, Throw,                                       Port.EffectfulFacade
 Bracket, Fresh,     Serializable-                     Repo
 Random, FxList,      Coroutine                      
 Yield,                                              
 EffectLogger,  AsyncCoroutine     FiberPool       Adapter
 Parallel,                                        Adapter.EffectfulContract
 AtomicState,                         
 Transaction,                                  
 Command                                       
                                     
                              Channel    Task   
                                               
                               Brook            
                                                
                                           Query.Contract
                                           QueryBlock
                                           (Haxl-like: auto-batches fetches
                                           via Coroutine fibers)

The old problem

Between pure business logic and side-effecting infrastructure sits the orchestration layer — "fetch the user, check permissions, load their subscription, hit some APIs, compute a price, write an invoice." This code encodes your most important business rules, but it's tangled with databases, APIs, and randomness — making it hard to test, hard to refactor, and often — impossible to property-test.

Another way

Skuld lets you write pure orchestration code that describes side effects without performing them — then handlers decide what those descriptions mean. The exact same "effectful" code runs with side-effecting handlers in production and pure in-memory handlers in tests — fully deterministic, fully pure, and straightforwardly property-testable.

The effect advantage

Effectful computations condense domain logic to its essence. Handlers provide context — production vs test, concurrency, batching — without touching the computation. Effects are first-class data: inspect them, serialise them, replay them. The same mechanism enables both examples below.

Composability

Effects compose with zero ceremony. This query function reads like straightforward sequential code, but when it runs, concurrency happens at two levels: within the defquery block (fetch_user and fetch_orders run together via dependency analysis), and across all streamed invocations — Brook.map runs 4 transforms concurrently, and FiberPool batches their deffetch calls into single round-trips:

defquery build_account_summary(user_id, month) do
  user <- AccountQueries.fetch_user(user_id)
  orders <- AccountQueries.fetch_orders(user_id, month)
  details <- Query.map(Enum.map(orders, & &1.id), &AccountQueries.fetch_order_details/1)
  build_account_summary(user, orders, details)
end

# Feed a stream of users through — 4 concurrent transforms, all deffetch
# calls batched together by FiberPool
comp do
  source <- Brook.from_enum(user_ids)
  summaries <- Brook.map(source, &build_account_summary(&1, "2026-01"), concurrency: 4)
  Brook.to_list(summaries)
end
|> Skuld.Query.with_executor(AccountQueries, AccountExecutor)
|> Channel.with_handler()
|> FiberPool.with_handler()
|> Comp.run!()

build_account_summary knows nothing about batch sizes, concurrency limits, database round-trips, or the other account summaries which also need to be built - it's pure domain logic. Everything else is handler wiring — swappable, testable, composable.

Full batch loading recipe →

Suspension & resumption

A pausable computation that implements a state machine as normal code. Branching is just if — no state enum or dispatch table. Every <- pattern-match is a validation gate and the else clause catches unhappy paths at any step:

defmodule LoanApp do
  use Skuld.Syntax

  alias Skuld.Effects.Yield

  @threshold 100_000

  defcomp apply do
    {:ok,
     %{
       employment: employment,
       income: income,
       employer_id: employer_id
     }} <- Yield.yield(:personal_info)

    {:ok, _} <- if employment == :self_employed do
      verify_business(employer_id)
    else
      verify_employer(employer_id)
    end

    {:ok, _} <- if income > @threshold do
      Yield.yield(:additional_verification)
    else
      {:ok, :skip}
    end

    {:ok, decision} <- Yield.yield(:review_and_submit)
    Underwriter.decide(employment, income)
  else
    other -> other
  end

  defcomp verify_business(employer_id) do
    {:ok, _} <- Yield.yield(:business_verification)
    do_verify_business(employer_id)
  else
    other -> other
  end
end

Run it from a LiveView with AsyncCoroutine:

# mount
{:ok, runner} = AsyncCoroutine.run(LoanApp.apply(), tag: :loan)

# handle_info — the state machine pauses at each yield
def handle_info({AsyncCoroutine, :loan, %ExternalSuspend{value: :personal_info}}, socket) do
  personal = Accounts.get_personal_info(socket.assigns.user_id)
  AsyncCoroutine.run(socket.assigns.runner, {:ok, personal})
  {:noreply, socket}
end

# Only reached for self-employed applicants
def handle_info({AsyncCoroutine, :loan, %ExternalSuspend{value: :business_verification}}, socket) do
  docs = socket.assigns.business_docs_form |> to_business_docs()
  AsyncCoroutine.run(socket.assigns.runner, {:ok, docs})
  {:noreply, socket}
end

def handle_info({AsyncCoroutine, :loan, {:ok, decision}}, socket) do
  {:noreply, assign(socket, decision: decision, step: :done)}
end

Test it — two paths through the same computation, deterministic, no processes, no stubs:

comp =
  LoanApp.apply()
  |> Yield.with_handler()
  |> Throw.with_handler()

# Self-employed, high income — all 4 yields
fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
fiber = Coroutine.run(fiber, {:ok, %{employment: :self_employed,
                                      income: 200_000, employer_id: nil}})
fiber = Coroutine.run(fiber, {:ok, %{license: "LIC-123"}})
fiber = Coroutine.run(fiber, {:ok, %{verified: true}})
%Coroutine.Completed{result: {:ok, _}} = Coroutine.run(fiber, {:ok, :submitted})

# Employed, low income — skips business and additional verification
fiber2 = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
fiber2 = Coroutine.run(fiber2, {:ok, %{employment: :employed,
                                        income: 50_000, employer_id: "ACME"}})
%Coroutine.Completed{result: {:ok, _}} = Coroutine.run(fiber2, {:ok, :submitted})

An event-decomposed state machine would encode every unique path as dispatch state — employment branches, income branches, interaction between them. Here the branches are just if in the code. Same computation, same handlers, same testability.

Full LiveView integration recipe →

Installation

Hex.pm

def deps do
  [
    {:skuld, "~> 0.28"}
  ]
end

Where next?

If you want to...Read
Understand the problem effects solveWhy Effects?
See how effects and handlers workHow It Works
Write your first computationGetting Started
State, Reader, Writer, Throw, Fresh, RandomFoundational Effects
Yield, Coroutines, FiberPool, Channels, AsyncCoroutines & Concurrency
Port, Repo, Hexagonal ArchitectureBoundaries
Eliminate N+1 queriesBatch Loading
Handler-swapping for deterministic testingTesting
Full effect and API referenceReference
Peek under the hood — CPS, evidence-passing, custom effectsHow It Really Works

License

MIT License — see LICENSE for details.


Why Effects? >