BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Why OrchStep: compose workflows like Lego bricks

The design idea behind OrchStep: you don't carve automation from one block of bash — you snap together small, typed, well-understood pieces. Functions, steps, tasks, and modules are the bricks; your orchstep.yml is the model.

Jun 26, 2026 OrchStep Team 7 minROLE: Tech LeadSCALE: Any

Most automation starts as one block of bash and grows by accretion. Every new requirement is another if, another ||, another exported variable bolted onto the same 300-line slab. It works right up until the day you need to change one part and discover you can't, because nothing in it is a part.

OrchStep is built on the opposite idea. You don't carve a pipeline out of one block — you snap it together from small, typed, well-understood pieces. Like Lego.

OrchStep composition: typed function bricks snap into tasks, which assemble with a reusable module into one workflow

That picture is the whole mental model. Let's name the bricks.

The bricks, from smallest to largest

Functions are the typed bricks. Every step calls exactly one function, and each function does one thing well: shell runs a command, http calls an API, assert checks a condition, transform reshapes data with JavaScript, render writes a file from a template, wait polls, git and prompt do what they say. You're never writing a function — you're placing one. (The full set is here.)

Steps are placed bricks. A step is a function plus its inputs and its safety rails — retry, timeout, catch, finally. The brick knows how to fail gracefully so you don't hand-roll it each time.

Tasks are stacks of steps — an ordered column that does one job (build, deploy, migrate). And tasks call other tasks, so a ship task is just a few bricks that happen to be other stacks:

orchstep.yml
name: ship
defaults:
  service: api
tasks:
  build:
    steps:
      - { name: compile, func: shell, do: 'echo "build {{ vars.service }}"' }
  test:
    steps:
      - { name: unit, func: shell, do: 'echo "test {{ vars.service }}"' }
  # `ship` is assembled from the other two tasks, plus one deploy step.
  ship:
    steps:
      - { name: do_build, task: build }   # call a task as a brick
      - { name: do_test,  task: test }
      - name: deploy
        func: shell
        do: 'echo "deploy {{ vars.service }}"'
        retry: { max_attempts: 3, interval: "1s", backoff_rate: 2.0 }

Modules are pre-built kits. When a stack is good enough to reuse — a canary/rollout/rollback deploy kit, a notification kit — you publish it as a module and import it into any project instead of copy-pasting it. One fix to the kit fixes every project that uses it. (Modules.)

Your orchstep.yml is the finished model — bricks, stacks, and kits assembled into the thing you actually run.

Why build this way

Composition isn't an aesthetic preference; it buys concrete properties that a slab of bash can't give you:

  • You read intent, not plumbing. task: build and retry: { max_attempts: 3 } say what happens. A for loop with exponential-backoff arithmetic says how, badly.
  • You reuse instead of copy-paste. The deploy kit lives in one place. Five services import it; none of them re-implement rollback.
  • You can see the model before you build it. orchstep run ship --dry-run resolves the whole assembly — every called task, every variable, which branch a gate takes — and prints it without executing a thing. You're inspecting the Lego instructions before snapping anything together. (Dry run.)
  • The same bricks run everywhere. One static binary, identical behavior locally, in any CI, and on Windows. The model you assembled on your laptop is the model that runs in production.
  • The bricks delegate; they don't replace. shell wraps the kubectl, terraform, aws, and docker you already run. OrchStep gives them structure — it doesn't reimplement them.

A worked intuition

Think about what changes when a requirement lands. "Add a contract-test suite before deploy."

  • In the slab: find the right spot among 300 lines, hope the set -e and the trap still behave, push a commit to test it.
  • In the model: add one brick — - { name: contract, task: contract-test } — and --dry-run to see it slot in. The blast radius is one line, and you saw the result before it ran.

That difference compounds. Every brick you add is bounded; every slab edit is a gamble.

Where this isn't the answer

Composition has a floor and a ceiling. If your "automation" is genuinely three commands you run by hand, a shell alias is fine — don't assemble a model for it. And if you need a scheduler, a queue, multi-tenant RBAC, and a control plane, you need a platform; OrchStep deliberately stops short of being one. It's for the wide middle: everything that outgrew a script but doesn't justify a platform.

What you actually gain

ConcernOne block of bashOrchStep composition
Add a stepedit the slab, hopeone brick, bounded
Reuse logic across reposcopy-pasteimport a module
"What will this do?"read every line--dry-run the assembled model
Error handlinghand-rolled each timeretry / catch / finally on the brick
Runs the same in CI"works on my machine"one binary, identical everywhere
Wrap existing toolsyes, tangledyes, structured (shell delegates)

Where to go next

The point isn't that bash is bad — it's that a pile of bricks beats a carved slab the moment you need to change one. curl -fsSL https://orchstep.dev/install.sh | sh and snap a few together.

#DESIGN-PATTERNS#COMPOSITION#MODULES#WHY-ORCHSTEP
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY