BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jul 2, 2026 OrchStep Team 8 minROLE: Platform EngineerSCALE: Any

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 well-designed single-responsibility module versus a god-module with hardcoded values and leaked internals

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 recommended

The 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.

orchstep-module.yml
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. replicas has a type, a default, and a desc. Nothing environment-specific is baked in — the consumer supplies it.
  • Documented exports. rollout lists its params (with types and which are required) and its returns. That's the API surface, and it's self-documenting.
  • Internal helpers prefixed _. _manifest is a real task the rollout uses, but it's deliberately left out of exports, so it isn't part of the public contract and you can change it freely.
  • Outputs satisfy returns. The ship step sets ref, 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.

ANTI-PATTERN
# 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"
PATTERN
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, a deploy-kit, and a notify-kit and let consumers compose them.
  • Hardcoded environment. registry.acme-prod.internal baked into a shell step means the module only works in one place. Read it from config: (and read secrets from the environment at run time — never put them in config:).
  • No version. Without a version: and a git tag, every consumer is pinned to "whatever main is 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 of exports.

What you gained

ConcernGod-moduleWell-designed module
Reuseall-or-nothingcompose the kit you need
Configurationhardcodedtyped config.schema with defaults
API surfaceimplicitdocumented exports (params + returns)
Refactoringbreaks consumersinternals 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

About to publish? Run orchstep module validate ./your-module/ --strict first — it's the same bar the registry holds you to.

#MODULES#DESIGN-PATTERNS#PLATFORM-ENGINEERING#BEST-PRACTICES
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY