What you'll learn
Modules turn a growing Node.js program into explicit dependency boundaries. You will build with modern ES modules, recognize CommonJS, use node: specifiers, and keep public APIs intentionally small.
By the end of this lesson, you'll be able to:
- Configure and use ES modules with import and export
- Recognize CommonJS require and module.exports
- Resolve file locations safely in an ES module
- Design modules without hidden globals or circular dependencies
Core mental model
Node.js becomes easier when you separate the JavaScript language from the runtime and the operating-system capabilities it exposes. Use this table as a decision guide.
| Concept | What it means | Decision rule |
|---|---|---|
| ES module | The standard static import/export format | Use it for new course projects and declare type: module in package.json |
| CommonJS | Node's original require/module.exports format | Understand it for existing packages; do not mix formats accidentally |
| Public API | The names a module deliberately exports | Export stable capabilities, not every internal helper |
| node: specifier | An explicit reference to a Node.js built-in module | Prefer node:path, node:fs, and similar forms for clarity |
Professional workflow
Build and verify Node.js programs from the terminal in small, observable steps.
- Group behavior by responsibility rather than by arbitrary file size.
- Create package.json and choose one module system deliberately.
- Export the smallest API a consumer needs and keep helpers private.
- Import dependencies at the top so the module graph remains visible.
- Run the program from a different working directory when paths matter.
- Remove dependency cycles by extracting a lower-level shared boundary.
Keep the feedback loop short
Guided code lab
Create a focused ES module
Only the supported calculations are exported. The formatter is an internal implementation detail.
function roundCurrency(value) {
return Math.round(value * 100) / 100;
}
export function subtotal(lines) {
return lines.reduce(
(sum, line) => sum + line.price * line.quantity,
0,
);
}
export function total(lines, taxRate = 0) {
return roundCurrency(subtotal(lines) * (1 + taxRate));
}Consume the public API
Static imports make dependencies visible before execution. The node: prefix clearly identifies a built-in module.
import { basename } from "node:path";
import { total } from "./pricing.js";
const lines = [{ price: 12.5, quantity: 2 }];
console.log(basename(import.meta.filename));
console.log(total(lines, 0.1));Recognize the CommonJS equivalent
CommonJS loads through require and exports through module.exports. Existing Node.js code and packages may still use it.
function subtotal(lines) {
return lines.reduce(
(sum, line) => sum + line.price * line.quantity,
0,
);
}
module.exports = { subtotal };Production practice
One system
Choose ESM or CommonJS per package and use explicit .cjs or .mjs files only at deliberate compatibility boundaries.
Narrow exports
Treat exports as a supported contract. Private helpers can change without breaking consumers.
Acyclic graph
Dependencies should generally point toward lower-level capabilities, not circle back into application entry points.
Common failure mode
Independent workshop
Split a small order calculator into validation, pricing, formatting, and application modules using ES modules.
Your finished workshop must include:
- A package.json declaring ES modules
- Named exports with at least one private helper
- No global mutable state
- A dependency sketch with no circular imports
Definition of done
Recap & quick check
Key takeaways
- ES modules are the standard format
- CommonJS remains important to recognize
- Exports define the public contract
- node: prefixes identify built-ins
- Module-relative and working-directory-relative paths solve different problems
Quick check
1. Which package.json field opts .js files into ES module interpretation?
2. What does node:path identify?
3. Where does CommonJS export values?
4. Why keep exports narrow?
Next: npm, Dependencies & Scripts