ESM module separation

Use for any Node.js project needing modular structure

Problem

Small CLI projects often begin as a single file and accumulate logic until the file becomes unwieldy — mixing rule definitions, core engine logic, and CLI I/O in the same module. This makes unit testing difficult (the CLI layer runs on import), sharing the engine across multiple entry points impossible, and onboarding harder because there is no clear boundary between what the program does and how it is invoked. Eliza faced this exact problem: the pattern-matching engine, the rule definitions, and the readline interface all needed to be independently usable and testable.

Solution

Separate the project into three distinct module layers, each with a single responsibility:

  1. Engine (engine.ts) — Pure logic with no I/O. Accepts input, applies rules, returns output. No console.log, no readline, no process.exit.
  2. Rules/Data (rules.ts or rules.json) — The data the engine operates on. Isolated so it can be swapped, extended, or loaded dynamically without touching engine logic.
  3. CLI (cli.ts or index.ts) — Wires the engine and rules together, handles I/O, manages process lifecycle.
src/
  engine.ts    # respond(input: string, rules: Rule[]): string
  rules.ts     # export const rules: Rule[] = [...]
  cli.ts       # import { respond } from "./engine"; import { rules } from "./rules";

With "type": "module" in package.json and "module": "NodeNext" in tsconfig.json, all imports use ESM syntax and .js extensions in import paths.

Usage

Apply from the start of any CLI project that has a non-trivial core logic component. The separation pays off immediately when:

  • Writing tests: import engine.ts directly without triggering the CLI
  • Adding a second interface (e.g., HTTP server, web UI): reuse engine.ts with a different I/O wrapper
  • Swapping rule sets: load different rule files for different agent personas or environments

The pattern is lightweight — three files, no framework, no additional tooling.

Trade-offs

  • Pro: Engine is purely functional and trivially testable. respond(input, rules) — no mocking required.
  • Pro: The CLI layer is thin enough to verify by inspection rather than automated testing.
  • Con: ESM module resolution in Node.js requires .js extensions in TypeScript source imports (even though the files are .ts). This is unintuitive and a common source of import errors when migrating from CommonJS.
  • Con: Dynamic require() patterns (e.g., loading rules from a user-specified path at runtime) require import() with createRequire workarounds in strict ESM mode.

See Also

Source

Synced from ops-core learning data. Original source: Eliza