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:
- Engine (
engine.ts) — Pure logic with no I/O. Accepts input, applies rules, returns output. Noconsole.log, noreadline, noprocess.exit. - Rules/Data (
rules.tsorrules.json) — The data the engine operates on. Isolated so it can be swapped, extended, or loaded dynamically without touching engine logic. - CLI (
cli.tsorindex.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.tsdirectly without triggering the CLI - Adding a second interface (e.g., HTTP server, web UI): reuse
engine.tswith 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
.jsextensions 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) requireimport()withcreateRequireworkarounds in strict ESM mode.
See Also
Source
Synced from ops-core learning data. Original source: Eliza