Most workflow engines work the same way: define a step graph in a YAML file or DSL, let an engine interpret it, and hope the execution matches what you imagined. I used this approach for years with n8n, Airflow, and even Temporal in an earlier version, before hitting the limits of declarative definitions when business logic gets complex.
I built IronFlow to solve this. It's a workflow engine where workflows are imperative Rust code: no YAML, no DSL. The engine persists every step, tracks costs, and exposes everything through a REST API.
A YAML workflow file works for simple cases: A then B then C. The trouble starts when you need: Nested conditions: if step 2 fails and step 1 produced a certain flag, skip step 3 but run step 4 with different parameters Conditional parallelism: fan-out on N items, but some items need different processing Granular error handling: retry with backoff on some steps, fail-fast on others, structured logs for debugging
In YAML, these scenarios produce condition trees that become unreadable. You end up writing code in "hooks" or "scripts" embedded in the YAML, which is coding in a template language instead of a real one.
Temporal solved this by offering workflows as code (Go, Java, TypeScript, Python). Their approach is right. But Temporal requires heavy infrastructure: a cluster with Cassandra or MySQL, a frontend service, a history service, a matching service. For a project that needs to orchestrate AI agents and shell commands, it's overkill.
The language choice for a workflow engine isn't neutral. The engine is an infrastructure component that runs continuously, manages concurrency, and manipulates state machines. Here's what motivated the Rust choice.
IronFlow's core is an FSM (finite state machine) managing each run's lifecycle. A run passes through precise states - Pending, Running, AwaitingApproval, Retrying, Completed, Failed, Cancelled - and transitions between these states are constrained.
In Rust, this constraint is expressed in the type system. The FSM rejects invalid transitions at compile time, not at runtime:
Each event can only be applied from certain states. Approved is only valid from AwaitingApproval. PickedUp is only valid from Pending. The transition table is explicit in the code:
In Go, this same logic would use switch on string or int. An invalid transition error would only appear at runtime. In Node, you wouldn't even have guarantees on event types.
A workflow engine must handle concurrency everywhere: multiple runs in parallel, concurrent steps within a single run, pending HTTP calls, workers polling the API. Tokio provides all of this with near-metal performance.
IronFlow uses an API + workers model. The API owns persistence and never executes anything. Workers poll the API for pending runs, execute them locally, and stream steps and logs back. Scaling out means starting more workers.
Each step runs in its own Tokio task. The ? propagates errors naturally. No callback hell, no promise chaining.
Go also produces a static binary, and it's a common argument in its favor. But Rust goes further with lto = true, strip = true and codegen-units = 1 in the release profile: the binary is more compact and starts faster.
For IronFlow, this means trivial deployment: a single file to copy to the server. No Node runtime, no JVM, no Python with its virtualenvs. The worker runs with a few MB of RAM, even under load.
An IronFlow workflow is a WorkflowHandler trait implementation. You receive a WorkflowContext and chain operations:
The control flow is standard Rust. If tests fail, ? propagates the error and the run transitions to Failed. The approval gate suspends the run until a human acts. No DSL to learn.
IronFlow supports 10 AI providers, all behind the same AgentProvider trait. A workflow written for Claude runs on any other provider without modification:
