Module design patterns (and anti-patterns)
A good OrchStep module has one job, a typed config schema, documented exports, and semver discipline. A bad one is a god-module with hardcoded env values and leaked internals. Here's how to tell them apart — with a manifest that passes validation.
Once you can package a workflow as a module, the next question is should you, and how? A module is an API: other teams import it by name, pass it inputs, and depend on its outputs. The same instincts that make a good library make a good module — and the same mistakes that rot a library rot a module faster, because a module runs shell commands on every consumer's machine.
This post is the difference between a module people reach for and one they fork to escape. Everything below is shaped to pass orchstep module validate --strict.
A module is two files
Every module is a manifest plus a workflow:
deploy-kit/
├── orchstep-module.yml # manifest: metadata, permissions, config schema, exports
├── orchstep.yml # the tasks that implement the exports
└── README.md # optional but recommendedThe manifest is the contract. The workflow is the implementation. Keeping them separate is what lets a consumer reason about what a module does without reading how.
Pattern: single responsibility, typed config, documented exports
Here's a deploy kit that does one thing. Read the manifest as a contract: it declares its identity under metadata:, asks for least-privilege permissions:, exposes exactly one typed config knob, and documents one export with its params and returns.
metadata: # identity lives UNDER metadata
name: deploy-kit
version: "1.2.0"
description: "Roll out a service the org-approved way"
author: "platform-team"
tags: ["deploy", "platform"]
permissions: # least privilege — only what you use
shell: true
http:
allow: [] # no outbound HTTP
git: false
config: # what a consumer may pass
schema:
properties:
replicas:
type: integer
required: false
default: 2
desc: "Number of replicas"
exports: # the ONE public task
rollout:
desc: "Roll out a service to an environment"
params:
- { name: service, type: string, required: true }
- { name: target, type: string, required: false, default: "staging" }
returns:
type: object
properties:
ref: { type: string }Five design decisions are doing the work here:
- One responsibility. It deploys. It doesn't also build, notify, and run migrations. Small modules compose; god-modules don't.
- Parameterized via
config.schema.replicashas atype, adefault, and adesc. Nothing environment-specific is baked in — the consumer supplies it. - Documented
exports.rolloutlists itsparams(with types and which arerequired) and itsreturns. That's the API surface, and it's self-documenting. - Internal helpers prefixed
_._manifestis a real task the rollout uses, but it's deliberately left out ofexports, so it isn't part of the public contract and you can change it freely. - Outputs satisfy
returns. Theshipstep setsref, which is exactly what the manifest promised.
Validate the contract
Before you tag a release, validate it. This checks the manifest fields, the workflow, and the exports as errors, and supply-chain concerns (dangerous shell, internal URLs, size) as warnings:
orchstep module validate ./deploy-kit/✓ orchstep-module.yml valid
✓ orchstep.yml valid
⚠ README.md not found (will be auto-generated for AI submissions)
Exports: [rollout]Exports: [rollout] confirms the public surface — _manifest correctly doesn't show up. Add --strict to treat warnings as errors; that's the exact gate orchstep module submit applies before anything lands in the @ai registry.
The anti-patterns
Most bad modules are a good one with one of these mistakes. The fix is always to push the variability back to the consumer.
# god-module: does everything, hardcodes the env, no version
metadata:
name: do-everything
# no version!
exports:
build_test_deploy_notify:
desc: "build, test, deploy, and notify"
tasks:
build_test_deploy_notify:
steps:
- name: ship
func: shell
# hardcoded registry + target — un-reusable
do: echo "deploy to registry.acme-prod.internal / production"metadata:
name: deploy-kit
version: "1.2.0" # semver, always
config:
schema:
properties:
registry: # consumer supplies it
type: string
required: true
desc: "Container registry URL"
exports:
rollout:
params:
- { name: target, type: string, required: true }The four that bite hardest:
- The god-module. One export that builds, tests, deploys, and notifies. Nobody can reuse any piece. Split it into a
build-kit, adeploy-kit, and anotify-kitand let consumers compose them. - Hardcoded environment.
registry.acme-prod.internalbaked into ashellstep means the module only works in one place. Read it fromconfig:(and read secrets from the environment at run time — never put them inconfig:). - No version. Without a
version:and a git tag, every consumer is pinned to "whatevermainis today." Tag with semver: PATCH for fixes, MINOR for features, MAJOR for breaking changes — and never break compatibility in a MINOR or PATCH. - Leaking internals. Exporting your helper tasks makes them part of the contract, so you can't refactor without breaking someone. Prefix internal tasks with
_and keep them out ofexports.
What you gained
| Concern | God-module | Well-designed module |
|---|---|---|
| Reuse | all-or-nothing | compose the kit you need |
| Configuration | hardcoded | typed config.schema with defaults |
| API surface | implicit | documented exports (params + returns) |
| Refactoring | breaks consumers | internals hidden behind _ |
| Upgrades | "latest, good luck" | semver tags + constraints |
Where this isn't worth it
If a workflow is used by exactly one project, don't pay the module tax — keep it as plain tasks in that repo. The manifest, the validation, the semver discipline all earn their keep the moment a second consumer appears. Design for that moment, not before it.
Where to go next
- Creating Modules — the full manifest format, layouts, and publishing
- Registry & Scopes — how your module's source resolves to a tag
- Using Modules — how consumers import and call what you publish
About to publish? Run orchstep module validate ./your-module/ --strict first — it's the same bar the registry holds you to.
curl -fsSL https://orchstep.dev/install.sh | sh