Hephaestus
A lightweight, compile-time validated workflow engine for Elixir. Define step-based DAG workflows with pattern matching, execute them with built-in support for branching, parallelism, and async operations — all backed by OTP.
Features
- Compile-time DAG validation — catches cycles, unreachable steps, missing terminals, and event mismatches before runtime
- Pure functional engine — immutable state transitions, easy to test and reason about
- Parallel execution — fan-out to multiple steps, fan-in with automatic predecessor synchronization
- Async-native — first-class support for long-running operations, timers, and external event resumption
- Pluggable adapters — swap storage (ETS, database, Redis) and runner (local OTP, distributed) implementations
- Context threading — immutable initial data plus namespaced step results flowing through the workflow
- Execution history — built-in audit trail of every step completion
Installation
Add hephaestus to your list of dependencies in mix.exs:
def deps do
[
{:hephaestus, "~> 0.1.0"}
]
endQuick Start
1. Configure Hephaestus
defmodule MyApp.Hephaestus do
use Hephaestus,
storage: Hephaestus.Runtime.Storage.ETS,
runner: Hephaestus.Runtime.Runner.Local
endAdd it to your supervision tree:
children = [
MyApp.Hephaestus
]2. Define Steps
Each step implements the Hephaestus.Steps.Step behaviour:
defmodule MyApp.Steps.ValidateOrder do
@behaviour Hephaestus.Steps.Step
@impl true
def events, do: [:valid, :invalid]
@impl true
def execute(_instance, _config, context) do
if context.initial[:item_count] > 0 do
{:ok, :valid, %{validated_at: DateTime.utc_now()}}
else
{:ok, :invalid}
end
end
endSteps return one of:
{:ok, event}— synchronous completion{:ok, event, context_updates}— completion with data{:async}— pause and wait for external resume{:error, reason}— failure
3. Define a Workflow
Workflows declare a start step, a business key via unique, and transitions between steps using pattern matching:
defmodule MyApp.Workflows.OrderFlow do
use Hephaestus.Workflow,
unique: [key: "orderid"]
@impl true
def start, do: MyApp.Steps.ValidateOrder
@impl true
def transit(MyApp.Steps.ValidateOrder, :valid, _ctx), do: MyApp.Steps.ProcessPayment
def transit(MyApp.Steps.ValidateOrder, :invalid, _ctx), do: MyApp.Steps.RejectOrder
def transit(MyApp.Steps.ProcessPayment, :paid, _ctx), do: Hephaestus.Steps.Done
def transit(MyApp.Steps.RejectOrder, :rejected, _ctx), do: Hephaestus.Steps.Done
end
The unique: [key: "orderid"] option is mandatory. It declares the business key used to identify instances (e.g., stored ID becomes "orderid::abc123"). The compiler validates the entire DAG at build time — if a path doesn't reach Hephaestus.Steps.Done, or if events don't match step declarations, you'll get a compile error.
4. Run It
Use the generated facade API on the workflow module — this is the preferred way to interact with workflows:
{:ok, "orderid::abc123"} = MyApp.Workflows.OrderFlow.start("abc123", %{item_count: 3, user_id: 42})
Or use the lower-level start_instance with an explicit id: option:
{:ok, instance_id} = MyApp.Hephaestus.start_instance(
MyApp.Workflows.OrderFlow,
%{item_count: 3, user_id: 42},
id: "orderid::abc123"
)5. Resume Async Workflows
For steps that return {:async}, resume them with an external event:
:ok = MyApp.Workflows.OrderFlow.resume("abc123", :payment_confirmed)
# Or via the lower-level API:
:ok = MyApp.Hephaestus.resume("orderid::abc123", :payment_confirmed)Workflow Patterns
Branching
def transit(StepA, :approved, _ctx), do: ApprovalPath
def transit(StepA, :rejected, _ctx), do: RejectionPathParallel Execution (Fan-out / Fan-in)
# Fan-out: multiple steps run concurrently
def transit(Start, :done, _ctx), do: [BranchA, BranchB, BranchC]
# Fan-in: all branches must complete before Join activates
def transit(BranchA, :done, _ctx), do: Join
def transit(BranchB, :done, _ctx), do: Join
def transit(BranchC, :done, _ctx), do: JoinDynamic Routing
Use @targets to declare possible destinations for context-dependent transitions:
@targets [FastTrack, StandardProcess]
def transit(Triage, :routed, ctx) do
if ctx.initial[:priority] == :high, do: FastTrack, else: StandardProcess
endTimers and External Events
# Wait for a duration
def transit(StepA, :done, _ctx), do: {Hephaestus.Steps.Wait, %{duration: 30, unit: :second}}
def transit(Hephaestus.Steps.Wait, :timeout, _ctx), do: StepB
# Wait for an external event
def transit(StepA, :done, _ctx), do: Hephaestus.Steps.WaitForEvent
def transit(Hephaestus.Steps.WaitForEvent, :received, _ctx), do: StepBBusiness Keys and Uniqueness
Every workflow must declare a business key via unique: [key: "..."]. The key becomes the ID prefix for all instances: "orderid::abc123".
The scope option controls where uniqueness is enforced (default: :workflow):
| Scope | Uniqueness per | Use case |
|---|---|---|
:workflow | {id, workflow_module} | One active instance per workflow (most common) |
:version | {id, workflow_module, version} | Blue-green deploys with parallel versions |
:global | {id} | Exclusive resource lock across all workflows |
:none | No constraint | Multiple concurrent instances (e.g., notifications) |
use Hephaestus.Workflow,
unique: [key: "blueprintid", scope: :workflow]Facade API
Each workflow module gets generated facade functions for convenient interaction:
MyWorkflow.start("abc123", %{amount: 100}) # -> {:ok, "orderid::abc123"}
MyWorkflow.resume("abc123", :payment_done) # -> :ok
MyWorkflow.get("abc123") # -> {:ok, %Instance{}}
MyWorkflow.list(status: :running) # -> [%Instance{}, ...]
MyWorkflow.cancel("abc123") # -> :okThe facade builds the composite ID internally — callers only pass the raw business value.
Architecture
┌─────────────────────────────────────────────┐
│ Core (pure) │
│ Workflow ─ Engine ─ Instance ─ Context │
└──────────────────┬──────────────────────────┘
│
┌──────────────────┴──────────────────────────┐
│ Runtime (OTP) │
│ Runner (Local/Custom) ─ Storage (ETS/Custom)│
└──────────────────┬──────────────────────────┘
│
┌──────────────────┴──────────────────────────┐
│ Steps │
│ Done ─ Wait ─ WaitForEvent ─ Debug ─ Custom│
└─────────────────────────────────────────────┘- Core — pure functional layer: workflow definition, DAG validation, engine state machine, instance/context structs
- Runtime — OTP layer: GenServer-based runner, pluggable storage, supervision tree, crash recovery
- Steps — behaviour-based units of work: built-in primitives plus your custom steps
Documentation
Generate docs with:
mix docsGenerate ASCII execution graphs in your workflow moduledocs:
mix hephaestus.gen.docsLicense
Copyright (c) 2025. All rights reserved.