did-btcr2-js

ADR 013: CLI Per-Command Modules with Dependency Injection

Status: Accepted

Date: 2026-03-17

Commit: 3d5135e

Context

The @did-btcr2/cli package shipped as a single DidBtcr2Cli class (~400 lines) that registered all four user-facing commands: create, resolve, update, and what would become deactivate: along with their argument parsing, option validation, result formatting, and error handling. Four concrete problems had accumulated by the v0.3 line:

  1. God-class growth. Command-specific logic (byte-length validation for create, JSON-option parsing for update, stdin handling for resolve) lived in private methods of DidBtcr2Cli. Adding a command meant touching the god class.
  2. Test monkey-patching. The spec files overwrote static methods on the concrete DidBtcr2 class to substitute behavior for tests. Every test file had its own ad-hoc monkey-patch pattern, and cleanup was fragile: a thrown error inside a test could leak the monkey-patched state into the next test.
  3. Silent-breakage bugs. update passed field names (patch, beacon) that didn’t match what DidBtcr2.update() expected (patches, beaconId). The command ran, emitted plausible-looking JSON, and produced no useful result. The mismatch was invisible without a live integration test.
  4. Version drift. The --version output was a hardcoded string literal inside cli.ts. Every package version bump required a manual touch in an unrelated file that was easy to forget.

There was also one missing command: deactivate. The spec defines deactivation as an update applying a specific deactivation patch. That logic existed in the method package but had no CLI entry point.

Options considered

  1. Keep the god class; add deactivate; add integration tests to catch field-name bugs. Lowest churn. Doesn’t address the testing pain, doesn’t address per-command isolation, doesn’t fix the hardcoded version.
  2. Extract command logic into plain functions exported from cli.ts. Better than #1 but leaves tests still depending on the god class’s wiring, and the functions would need a shared harness of some kind anyway.
  3. Per-command modules, each registering itself against a shared Commander program, with operations passed in via a DI interface. Every command becomes a self-contained unit: its own validation, its own action handler, its own test file: and tests inject mock operations instead of monkey-patching globals.

Decision

Option 3. Four changes, landing together:

Subsequent evolution: MethodOperations was later replaced by the ApiFactory pattern when lazy API construction landed (see ADR 024). The per-command module split and the DI seam at the command boundary survived that migration: the commands just take a factory now instead of an ops object. The architectural decision captured here is the split and inject pattern, not the specific MethodOperations shape.

Consequences

Positive

Negative

Explicitly accepted trade-offs

References