Skuld
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 all three 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.
Suspension & resumption
A pausable state machine. The computation pauses at each Yield,
waits 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 state machine 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 machine 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 state machine in a single function — deterministic, no processes, no stubs.
Full LiveView integration recipe →
Durability
The same Checkout state machine, above, but now serialised: pause it
mid-flight, save its entire execution history as JSON, and resume it
later — after a restart, on a different machine. Every effect invocation
is captured in a serialisable log:
sc =
SerializableCoroutine.new(Checkout.run(), 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))
# => EffectLogEntry{data: :get_cart, value: nil, state: :started}
# Later — cold resume from JSON, no manual deserialisation needed
suspended2 = SerializableCoroutine.run(json, sc, %{items: [...]})
# => EffectLogEntry{data: :get_cart, value: %{items: [...]}, state: :executed}
# => EffectLogEntry{data: :get_payment, value: nil, state: :started}
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.28"}
]
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 | Batch Loading |
| Handler-swapping for deterministic testing | Testing |
| Full effect and API reference | Reference |
| Peek under the hood — CPS, evidence-passing, custom effects | How It Really Works |
License
MIT License — see LICENSE for details.