Skuld
View SourceAn 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.
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
endRun 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)}
endTest 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
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.