Skuld
An effectful programming framework for Elixir.
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 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.
Because effects are first-class data, Skuld can do more — batch independent queries automatically, serialise partially complete computations for later resumption.
Quick example
A multi-step checkout wizard. The computation pauses at each step, waiting for external input — then resumes with full effect context preserved:
defmodule Checkout do
use Skuld.Syntax
alias Skuld.Effects.Yield
defcomp run do
cart <- Yield.yield(:get_cart)
{:ok, inventory} <- Inventory.check_stock(cart.items)
payment <- Yield.yield(:get_payment)
{:ok, order} <- Orders.place(cart, payment)
_ <- Emailer.send_confirmation(order)
{:ok, order}
end
end
Run it from a LiveView with AsyncCoroutine:
# mount
{:ok, runner} = AsyncCoroutine.run(Checkout.run(), tag: :checkout)
# handle_info — the wizard pauses at each yield
def handle_info({AsyncCoroutine, :checkout, %ExternalSuspend{value: :get_cart}}, socket) do
cart = ShoppingCart.get_cart(socket.assigns.user)
AsyncCoroutine.run(socket.assigns.runner, cart) # resume with cart
{:noreply, socket}
end
def handle_info({AsyncCoroutine, :checkout, %ExternalSuspend{value: :get_payment}}, socket) do
payment = socket.assigns.payment_form |> to_payment_method()
AsyncCoroutine.run(socket.assigns.runner, payment) # resume with payment
{:noreply, socket}
end
def handle_info({AsyncCoroutine, :checkout, {:ok, order}}, socket) do
{:noreply, assign(socket, order: order, step: :done)}
endTest it — drive the whole wizard in a few lines:
comp =
Checkout.run()
|> Port.with_handler(%{Inventory => Inventory.Test, Orders => Orders.Test})
|> Port.with_test_handler(%{Port.key(Emailer, :send_confirmation, [_]) => :ok})
|> Yield.with_handler()
|> Throw.with_handler()
fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run() # pauses at :get_cart
fiber = Coroutine.run(fiber, %{items: [...]}) # pauses at :get_payment
%Coroutine.Completed{result: {:ok, order}} =
Coroutine.run(fiber, %{card: "4242..."}) # completesSame code. Production pauses at each step for user input. Tests drive the entire wizard in a single function — deterministic, no processes, no stubs.
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
user_ids
|> Brook.from_enum()
|> Brook.map(&build_account_summary(&1, "2026-01"), concurrency: 4)
|> Brook.to_list()
|> 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.
Durability
Effects are data you can persist. Pause a multi-step wizard, save its entire execution history as JSON, and resume it later — after a restart, on a different machine:
wizard =
comp do
name <- Yield.yield(:get_name)
email <- Yield.yield(:get_email)
{:ok, %{name: name, email: email}}
end
sc =
SerializableCoroutine.new(wizard, fn comp ->
comp |> Yield.with_handler() |> Throw.with_handler()
end)
# Run until suspension, serialize the effect log
suspended = SerializableCoroutine.run(sc)
json = SerializableCoroutine.serialize(SerializableCoroutine.get_log(suspended))
# Later — cold resume from JSON, no manual deserialisation needed
SerializableCoroutine.run(json, sc, "Alice")
Every effect invocation — yields, state changes, writer events —
is captured in the log. run replays recorded effects and resumes
at the suspension point. The same mechanism that enables batching
in the composability example enables durability here.
Full durable computation recipe →
Installation
def deps do
[
{:skuld, "~> 0.27"}
]
endWhere next?
| If you want to... | Read |
|---|---|
| Understand the problem effects solve | Why Effects? |
| See how effects and handlers work | How It Works |
| Write your first computation | Getting Started |
| State, Reader, Writer, Throw, Fresh, Random | Foundational Effects |
| Yield, Coroutines, FiberPool, Channels, Async | Coroutines & Concurrency |
| Port, Repo, Hexagonal Architecture | Boundaries |
| Eliminate N+1 queries | Query System |
| Handler-swapping for deterministic testing | Testing |
| Full effect and API reference | Reference |
License
MIT License — see LICENSE for details.