Architecture Patterns for Production CLI Tools in Node.js
Architecture Patterns for Production CLI Tools in Node.js
Building a CLI tool that works is easy. Building one that scales, tests cleanly, and survives contact with real users is a different problem entirely. After shipping over 30 npm CLI tools — from simple utilities to full-featured developer platforms — I've converged on a set of architecture patterns that separate throwaway scripts from production-grade software.
This article covers the structural decisions that matter most: how to organize commands, inject dependencies, layer configuration, support plugins, handle errors gracefully, and shut down cleanly. Every pattern comes from real production code.
The Command Pattern: Separating Parsing from Logic
The single most impactful architectural decision in a CLI tool is separating argument parsing from business logic. Most tutorials show a monolithic approach where yargs or commander callbacks contain the actual work. This is a trap.
The command pattern treats each CLI command as an independent module with a clear interface:
// commands/deploy.js
export const command = {
name: 'deploy',
description: 'Deploy application to target environment',
args: [
{ name: 'environment', required: true, choices: ['staging', 'production'] },
{ name: '--force', type: 'boolean', default: false },
{ name: '--dry-run', type: 'boolean', default: false },
],
async execute(args, context) {
const { environment, force, dryRun } = args;
const { logger, config, deployer } = context;
logger.info(`Deploying to ${environment}...`);
if (dryRun) {
const plan = await deployer.plan(environment);
logger.output(plan);
return { status: 'dry-run', plan };
}
if (environment === 'production' && !force) {
throw new UserError('Production deploy requires --force flag');
}
const result = await deployer.execute(environment);
return { status: 'deployed', ...result };
},
};
The key insight: execute receives a context object rather than importing dependencies directly. The CLI framework's job is to parse arguments, build the context, and call execute. The command's job is to do work. These are different responsibilities.
┌─────────────────────────────────────────────────┐
│ CLI Entry │
│ ┌───────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Arg │→ │ Context │→ │ Command │ │
│ │ Parser │ │ Builder │ │ Router │ │
│ └───────────┘ └──────────┘ └──────┬───────┘ │
│ │ │
│ ┌────────────────────────────┼──────┐ │
│ │ │ │ │ │
│ ┌───▼──┐ ┌──▼───┐ ┌───▼──┐ ┌▼────┐ │ │
│ │deploy│ │ init │ │ list │ │ ... │ │ │
│ └──────┘ └──────┘ └──────┘ └─────┘ │ │
│ │ Command Layer │ │
│ └──────────────────────────────────┘ │
│ │ │
│ ┌────────────▼─────────────────────┐ │
│ │ Service Layer │ │
│ │ deployer, config, logger, api │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
This separation means you can test every command without spinning up a CLI parser. You can reuse commands programmatically. You can swap the parsing library without touching business logic. I've migrated tools from yargs to commander to custom parsers — the command modules never changed.
Dependency Injection for Testable CLIs
The context object in the command pattern above is dependency injection (DI) in its simplest form. No framework needed — just pass things in.
// context.js
export function createContext(options = {}) {
const config = options.config || loadConfig();
const logger = options.logger || createLogger(config.logLevel);
const httpClient = options.httpClient || createHttpClient(config);
const deployer = options.deployer || new Deployer(httpClient, config);
const output = options.output || createOutputFormatter(config.format);
return { config, logger, httpClient, deployer, output };
}
In production, createContext() wires up real implementations. In tests, you inject fakes:
import { test } from 'node:test';
import { command } from './commands/deploy.js';
test('deploy --dry-run shows plan without executing', async (t) => {
const logs = [];
const context = {
logger: { info: (msg) => logs.push(msg), output: () => {} },
config: { environment: 'staging' },
deployer: {
plan: async () => ({ changes: 3, additions: 1 }),
execute: async () => { throw new Error('should not be called'); },
},
};
const result = await command.execute(
{ environment: 'staging', dryRun: true, force: false },
context
);
assert.strictEqual(result.status, 'dry-run');
assert.strictEqual(result.plan.changes, 3);
});
No mocking libraries. No module patching. No process spawning. Tests run in milliseconds. This alone saves more time than any other pattern in this article.
Configuration Hierarchy: Args > Env > Config File > Defaults
Production CLIs need a clear, predictable configuration resolution order. Users should always know which value wins. The standard hierarchy, from highest to lowest priority:
- Command-line arguments — explicit user intent for this invocation
- Environment variables — session or CI/CD context
- Project config file —
.toolrc,tool.config.js, or apackage.jsonfield - User-level config —
~/.config/tool/config.json - Built-in defaults — sensible fallbacks
export function resolveConfig(args) {
const defaults = {
format: 'text',
logLevel: 'info',
timeout: 30000,
color: true,
};
const userConfig = loadUserConfig(); // ~/.config/tool/config.json
const projectConfig = loadProjectConfig(); // .toolrc or package.json["tool"]
const envConfig = loadEnvConfig(); // TOOL_FORMAT, TOOL_LOG_LEVEL, etc.
return {
...defaults,
...userConfig,
...projectConfig,
...envConfig,
...stripUndefined(args),
};
}
function loadEnvConfig() {
return stripUndefined({
format: process.env.TOOL_FORMAT,
logLevel: process.env.TOOL_LOG_LEVEL,
timeout: process.env.TOOL_TIMEOUT ? Number(process.env.TOOL_TIMEOUT) : undefined,
color: process.env.NO_COLOR ? false : undefined,
});
}
function stripUndefined(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined)
);
}
Two critical details often missed:
Respect NO_COLOR. The no-color.org convention is widely adopted. If NO_COLOR is set (to any value), disable color output. Many CI environments set this.
Config file discovery should walk up. Look for .toolrc in the current directory, then parent, then grandparent, up to the filesystem root or home directory. This matches how .gitignore, .eslintrc, and .npmrc work — users expect it.
Plugin Architecture: Extending CLIs with Third-Party Code
Once a CLI tool reaches a certain complexity, users want to extend it. A plugin architecture lets third-party code add commands, modify behavior, or integrate with external systems.
The simplest effective plugin system uses a convention-based approach:
// plugin-loader.js
export async function loadPlugins(config) {
const plugins = [];
for (const spec of config.plugins || []) {
const pluginModule = typeof spec === 'string'
? await import(spec)
: await import(spec.name);
const options = typeof spec === 'string' ? {} : spec.options;
const plugin = pluginModule.default || pluginModule;
if (typeof plugin.register !== 'function') {
throw new Error(`Plugin ${spec} must export a register() function`);
}
plugins.push({ plugin, options });
}
return plugins;
}
// Plugin contract
export async function register(cli, options) {
// Add new commands
cli.addCommand({
name: 'custom-deploy',
description: 'Deploy with custom provider',
async execute(args, context) {
// plugin logic here
},
});
// Add middleware (pre/post hooks)
cli.addHook('before:deploy', async (args, context) => {
context.logger.info('Running pre-deploy checks from plugin...');
});
// Extend configuration schema
cli.extendConfig({
customProvider: { type: 'string', default: 'aws' },
});
}
The register(cli, options) contract gives plugins a scoped API surface. They can add commands, register hooks, and extend configuration — but they can't monkey-patch internals. This boundary is important: it means you can change internal implementation without breaking plugins.
Naming convention matters. Use toolname-plugin-* as the npm package prefix. This lets users discover plugins via npm search, and lets your loader auto-discover installed plugins by scanning node_modules.
Middleware Pattern: Pre/Post Hooks for Commands
Middleware — code that runs before or after every command (or specific commands) — handles cross-cutting concerns: authentication checks, telemetry, timing, input validation, output formatting.
class MiddlewareChain {
#hooks = { before: [], after: [], error: [] };
addHook(phase, fn, options = {}) {
const entry = {
fn,
commands: options.commands || '*', // which commands this applies to
priority: options.priority || 100, // lower runs first
};
this.#hooks[phase].push(entry);
this.#hooks[phase].sort((a, b) => a.priority - b.priority);
}
async execute(commandName, args, context, commandFn) {
const matchesCommand = (entry) =>
entry.commands === '*' || entry.commands.includes(commandName);
// Run "before" hooks
for (const hook of this.#hooks.before.filter(matchesCommand)) {
await hook.fn(args, context);
}
let result;
try {
result = await commandFn(args, context);
} catch (error) {
// Run "error" hooks
for (const hook of this.#hooks.error.filter(matchesCommand)) {
await hook.fn(error, args, context);
}
throw error;
}
// Run "after" hooks
for (const hook of this.#hooks.after.filter(matchesCommand)) {
await hook.fn(result, args, context);
}
return result;
}
}
Practical middleware I use in every production CLI:
// Authentication check
middleware.addHook('before', async (args, context) => {
if (!context.config.apiKey) {
throw new UserError('Not authenticated. Run `tool login` first.');
}
}, { commands: ['deploy', 'publish', 'delete'], priority: 10 });
// Execution timer
middleware.addHook('before', async (args, context) => {
context._startTime = performance.now();
}, { priority: 1 });
middleware.addHook('after', async (result, args, context) => {
const elapsed = performance.now() - context._startTime;
context.logger.debug(`Completed in ${elapsed.toFixed(0)}ms`);
}, { priority: 999 });
// Telemetry (opt-in)
middleware.addHook('after', async (result, args, context) => {
if (context.config.telemetry) {
await reportUsage(context.commandName, { success: true });
}
}, { priority: 900 });
Output Abstraction: JSON, Table, Plain Text, Silent
Production CLIs are used by humans in terminals and by machines in scripts. Supporting multiple output formats is not optional — it's table stakes.
class OutputFormatter {
#format;
#stream;
#silent;
constructor(options = {}) {
this.#format = options.format || 'text';
this.#stream = options.stream || process.stdout;
this.#silent = options.silent || false;
}
output(data, options = {}) {
if (this.#silent) return;
const formatted = this.#formatData(data, options);
this.#stream.write(formatted + '\n');
}
#formatData(data, options) {
switch (this.#format) {
case 'json':
return JSON.stringify(data, null, 2);
case 'jsonl':
return Array.isArray(data)
? data.map((item) => JSON.stringify(item)).join('\n')
: JSON.stringify(data);
case 'table':
return this.#formatTable(data, options.columns);
case 'csv':
return this.#formatCsv(data, options.columns);
case 'text':
default:
return options.textFormatter
? options.textFormatter(data)
: this.#defaultTextFormat(data);
}
}
#formatTable(data, columns) {
const rows = Array.isArray(data) ? data : [data];
if (rows.length === 0) return '(no results)';
const cols = columns || Object.keys(rows[0]);
const widths = cols.map((col) =>
Math.max(col.length, ...rows.map((r) => String(r[col] ?? '').length))
);
const header = cols.map((c, i) => c.padEnd(widths[i])).join(' ');
const separator = widths.map((w) => '─'.repeat(w)).join('──');
const body = rows.map((row) =>
cols.map((c, i) => String(row[c] ?? '').padEnd(widths[i])).join(' ')
).join('\n');
return `${header}\n${separator}\n${body}`;
}
}
The key principle: commands return data, the formatter renders it. Commands should never call console.log directly. They return structured objects, and the output layer decides how to present them. This makes --format json trivial rather than a retrofit.
┌──────────┐ ┌──────────────┐ ┌──────────────────┐
│ Command │────▶│ Output │────▶│ Renderer │
│ returns │ │ Router │ │ │
│ data │ │ (--format) │ │ json │ table │
└──────────┘ └──────────────┘ │ csv │ text │
│ jsonl│ silent │
└──────────────────┘
Error Boundaries: Catching and Formatting Errors per Command
Unhandled exceptions in a CLI tool produce ugly stack traces that confuse users. Production tools need error boundaries — catch points that intercept errors and produce helpful, formatted output.
Distinguish between two error categories:
class UserError extends Error {
constructor(message, { code, suggestion } = {}) {
super(message);
this.name = 'UserError';
this.code = code;
this.suggestion = suggestion;
}
}
class InternalError extends Error {
constructor(message, { cause } = {}) {
super(message);
this.name = 'InternalError';
this.cause = cause;
}
}
UserError means the user did something wrong (bad args, missing config, invalid state). Show a clean message with a suggestion for fixing it. InternalError means the tool broke. Show an apology and a way to report the bug.
function handleError(error, context) {
if (error instanceof UserError) {
context.logger.error(`Error: ${error.message}`);
if (error.suggestion) {
context.logger.info(`Suggestion: ${error.suggestion}`);
}
process.exitCode = error.code || 1;
} else if (error.code === 'ENOENT') {
context.logger.error(`File not found: ${error.path}`);
process.exitCode = 1;
} else if (error.code === 'EACCES') {
context.logger.error(`Permission denied: ${error.path}`);
context.logger.info('Try running with appropriate permissions.');
process.exitCode = 1;
} else {
context.logger.error('An unexpected error occurred.');
context.logger.debug(error.stack);
context.logger.info(
'Please report this issue: https://github.com/org/tool/issues'
);
process.exitCode = 2;
}
}
Never call process.exit() directly inside commands. Set process.exitCode and let the process exit naturally. This allows event loop cleanup, ensures streams flush, and lets finally blocks and after hooks run.
Async Command Execution with Cancellation Support
Long-running CLI operations need cancellation support. Node.js gives us AbortController and AbortSignal — use them consistently throughout the stack.
async function runCommand(command, args, context) {
const controller = new AbortController();
const { signal } = controller;
// Wire up Ctrl+C to abort
const onSigInt = () => {
context.logger.info('\nCancelling...');
controller.abort();
};
process.once('SIGINT', onSigInt);
try {
// Pass signal into command context
const result = await command.execute(args, { ...context, signal });
return result;
} finally {
process.removeListener('SIGINT', onSigInt);
}
}
// Inside a command — respecting cancellation
async execute(args, context) {
const { signal } = context;
const files = await findFiles(args.pattern);
for (const file of files) {
if (signal.aborted) {
throw new Error('Operation cancelled');
}
await processFile(file, { signal }); // pass signal to async operations
}
}
Pass signal to fetch(), fs operations, and any async work that supports it. This gives you cooperative cancellation throughout the call stack. When the user presses Ctrl+C, current work stops cleanly rather than leaving partial state.
Logging Levels: Debug, Verbose, Info, Warn, Error
A production logger for CLI tools needs to do three things: respect log levels, write to the correct stream, and stay out of the way of structured output.
const LEVELS = { debug: 0, verbose: 1, info: 2, warn: 3, error: 4, silent: 5 };
function createLogger(level = 'info') {
const threshold = LEVELS[level] ?? LEVELS.info;
const log = (lvl, stream, ...args) => {
if (LEVELS[lvl] >= threshold) {
const prefix = lvl === 'info' ? '' : `[${lvl}] `;
stream.write(prefix + args.join(' ') + '\n');
}
};
return {
debug: (...args) => log('debug', process.stderr, ...args),
verbose: (...args) => log('verbose', process.stderr, ...args),
info: (...args) => log('info', process.stderr, ...args),
warn: (...args) => log('warn', process.stderr, ...args),
error: (...args) => log('error', process.stderr, ...args),
output: (...args) => process.stdout.write(args.join(' ') + '\n'),
};
}
Critical detail: log messages go to stderr, structured output goes to stdout. This lets users pipe command output to files or other tools while still seeing progress and errors in the terminal:
tool list --format json > results.json # logs visible in terminal, JSON in file
tool deploy 2>deploy.log # output visible, logs captured
The output method writes to stdout unconditionally — it's for the command's actual result. Everything else goes to stderr with level gating. This pattern is standard in Unix tools and should be standard in Node.js CLIs.
Signal Handling and Graceful Shutdown
Production CLIs must handle signals properly. Users press Ctrl+C. CI/CD systems send SIGTERM. Containers get killed. Your tool needs to clean up.
class GracefulShutdown {
#cleanupHandlers = [];
#shutdownInProgress = false;
register(handler, label) {
this.#cleanupHandlers.push({ handler, label });
}
install() {
const shutdown = async (signal) => {
if (this.#shutdownInProgress) {
// Second signal = force exit
process.exit(128 + (signal === 'SIGINT' ? 2 : 15));
}
this.#shutdownInProgress = true;
for (const { handler, label } of this.#cleanupHandlers.reverse()) {
try {
await Promise.race([
handler(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 5000)
),
]);
} catch {
// cleanup failed — log and continue
}
}
process.exitCode = 128 + (signal === 'SIGINT' ? 2 : 15);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
}
Key behaviors:
- First signal: begin graceful shutdown — close connections, remove temp files, flush buffers.
- Second signal: force exit immediately. Users expect this.
- Timeout: if cleanup takes more than 5 seconds, skip it. Don't hang.
- Reverse order: clean up in LIFO order. Resources created last should be destroyed first.
Register cleanup handlers at resource creation time:
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tool-'));
shutdown.register(
() => fs.rm(tmpDir, { recursive: true, force: true }),
'temp directory cleanup'
);
const dbConnection = await connect(config.database);
shutdown.register(
() => dbConnection.close(),
'database connection'
);
Putting It All Together
Here is how these patterns compose into a complete CLI architecture:
┌─────────────────────────────────────────────────────────┐
│ Entry Point │
│ (bin/tool.js) │
├──────────┬──────────┬──────────┬─────────┬──────────────┤
│ Config │ Plugin │ Context │ Arg │ Middleware │
│ Loader │ Loader │ Builder │ Parser │ Chain │
├──────────┴──────────┴──────────┴─────────┴──────────────┤
│ Command Router │
├────────┬────────┬────────┬────────┬─────────────────────┤
│ deploy │ init │ list │ login │ ... (+ plugins) │
├────────┴────────┴────────┴────────┴─────────────────────┤
│ Service Layer │
│ HTTP Client │ Deployer │ Auth │ File System │
├──────────────────┴───────────┴────────┴─────────────────┤
│ Cross-Cutting Concerns │
│ Logger │ Output Formatter │ Error Handler │ Signal │
└──────────┴────────────────────┴─────────────────┴────────┘
The initialization sequence:
- Load configuration (defaults, user config, project config, env vars, args)
- Create logger with resolved log level
- Load and register plugins
- Build context with all services
- Set up middleware chain (auth, timing, telemetry)
- Install signal handlers
- Parse arguments and route to command
- Execute command through middleware chain
- Format and output result
- Exit with appropriate code
#!/usr/bin/env node
import { parseArgs } from './parser.js';
import { resolveConfig } from './config.js';
import { createContext } from './context.js';
import { loadPlugins } from './plugins.js';
import { MiddlewareChain } from './middleware.js';
import { GracefulShutdown } from './shutdown.js';
import { handleError } from './errors.js';
import { commands } from './commands/index.js';
async function main() {
const shutdown = new GracefulShutdown();
shutdown.install();
const rawArgs = parseArgs(process.argv.slice(2));
const config = resolveConfig(rawArgs);
const context = createContext({ config });
const middleware = new MiddlewareChain();
// Load plugins
const plugins = await loadPlugins(config);
for (const { plugin, options } of plugins) {
await plugin.register({ addCommand: commands.add, addHook: middleware.addHook }, options);
}
// Register shutdown cleanup
shutdown.register(() => context.httpClient?.close(), 'http client');
// Route and execute
const command = commands.get(rawArgs.command);
if (!command) {
context.logger.error(`Unknown command: ${rawArgs.command}`);
process.exitCode = 1;
return;
}
try {
const result = await middleware.execute(
rawArgs.command, rawArgs, context, command.execute
);
if (result !== undefined) {
context.output.output(result);
}
} catch (error) {
handleError(error, context);
}
}
main();
Lessons from Production
A few hard-won observations that don't fit neatly into patterns:
Test the binary, not just the modules. Unit tests for individual commands are fast and valuable. But also have integration tests that spawn the actual CLI process, pass real arguments, and assert on stdout/stderr and exit codes. These catch wiring bugs that unit tests miss.
Version your config format. Add a version field to config files. When you need to change the schema, you can migrate gracefully instead of breaking existing installations.
Make --help actually helpful. Show examples, not just option descriptions. Users copy-paste examples far more often than they read flag descriptions.
Exit codes are an API. Document them. Use 0 for success, 1 for user errors, 2 for internal errors. CI/CD pipelines and shell scripts depend on consistent exit codes.
stdin is an input source too. If your tool processes data, accept it from stdin when no file argument is given. This enables piping, which is the Unix way.
These patterns aren't theoretical. They're the result of building tools that real people depend on, tools that run in CI/CD pipelines, get installed globally, and break in creative ways. The investment in architecture pays off the first time you need to add a command, fix a bug, or debug a user's environment. Structure your CLI like the production software it is.