Modular Organization
Splitting code across files with ES Modules and CommonJS — and the principles that make module boundaries useful.
Every program starts as a single file. Then it grows. At some point — usually around a few hundred lines — that single file becomes painful to navigate, and you split it. How you split it is what we mean by modular organization.
Good module boundaries make a codebase navigable for years. Bad ones make it a tangled mess. The good news is that a few principles take you 90% of the way.
What is a module?
A module is a file (or package) that:
- has its own private scope, and
- explicitly chooses which names it shares with the outside world.
Two modules can both declare a variable called helper without
clashing, because each name only exists inside its own file. That
isolation is the entire point.
users.js and orders.js both have a private helper — neither
file knows about the other's.
Two module systems in JavaScript
JavaScript has, historically, two module systems you'll see in the wild:
- CommonJS (CJS) — older, used by Node.js by default for
years. Uses
require()andmodule.exports. - ES Modules (ESM) — the official language standard, used by
browsers and now widely by Node. Uses
importandexport.
The runtime used in these lessons (almostnode) uses CommonJS,
so all the multi-file challenges have looked like this:
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// index.js
const { add } = require("./math");
console.log(add(2, 3));ES Modules look like this:
// math.js
export function add(a, b) { return a + b; }
// index.js
import { add } from "./math.js";
console.log(add(2, 3));The ideas are identical: define a public surface, import from other modules. Most projects today use ESM for new code. CommonJS is still everywhere because of legacy and Node defaults.
Quick mapping
| CommonJS | ES Modules |
|---|---|
const x = require("./mod") | import x from "./mod.js" |
const { a, b } = require("./mod") | import { a, b } from "./mod.js" |
module.exports = something | export default something |
module.exports = { a, b } | export { a, b } |
module.exports.a = ... | export const a = ... |
A subtle but important difference: ES module imports are static (resolved before any code runs), CommonJS requires are dynamic (executed as the line is reached). This is why ESM can do tree-shaking and CJS generally can't.
Designing a module: public vs. private
A module's public surface is everything it exports. Everything else is private — usable only from inside the file. The golden rule:
Export the minimum. Internal helpers stay internal.
Why? Because anything you export becomes a promise to callers. Renaming or removing it might break them. The smaller your surface, the freer you are to change the internals.
Inside currency.js, pad and SYMBOLS exist; outside, they
don't. We could rename them tomorrow and nothing else breaks.
Cohesion and coupling
Two related ideas guide what belongs in a module:
- Cohesion — how related the things inside a module are. High cohesion = good.
- Coupling — how dependent two modules are on each other. Low coupling = good.
A module that does "user authentication and PDF rendering" has low cohesion — unrelated concerns crammed together. Split it.
Two modules where each one constantly reaches into the other's internals are tightly coupled. Hide the internals; make the collaboration go through a small, named API.
The phrase: high cohesion, loose coupling. Repeat it until you believe it.
File layout patterns
For small projects, this is plenty:
src/
index.js
config.js
users.js
orders.js
utils.jsAs things grow, group by feature, not by technical type.
Anti-pattern (grouped by type):
src/
components/ # 200 unrelated files
utils/ # 100 unrelated files
models/ # 80 unrelated filesBetter (grouped by feature):
src/
users/
index.js
api.js
validation.js
orders/
index.js
api.js
pricing.js
shared/
http.js
config.jsWhen you change "the orders feature", almost all the code lives
in orders/. You don't have to spelunk across the whole tree.
Dependency direction
Modules should form a directed acyclic graph — A may depend on B, B may depend on C, but C must not depend on A.
shared/http.js is depended on by feature modules; it never
depends on them. This forms layers:
- Shared utilities (lowest layer) — depended on by everything.
- Feature modules — depended on by entry points.
- Entry points (highest layer) — depend on features.
A circular dependency — A imports B and B imports A — usually means a third module is hiding between them, waiting to be extracted.
A bigger multi-file example
Let's wire up a tiny CLI-ish app that demonstrates layering and re-exports.
A few things to notice:
users.jsandorders.jsare peers — neither imports the other.- Both depend on
shared/store.js, which depends on nothing. index.jsis the only place where everything comes together.
You could replace shared/store.js with a database tomorrow, and
the feature modules wouldn't care — they only depend on the
{ add, getAll } shape.
Re-exports: a single front door per package
A common pattern in bigger projects is for each "feature" folder
to have an index.js that re-exports its public surface:
// users/index.js
const { addUser, listActiveUsers } = require("./api");
module.exports = { addUser, listActiveUsers };
// elsewhere
const { addUser } = require("./users"); // not ./users/apiOutside callers go through ./users, never ./users/api. The
folder itself becomes the module boundary.
Project-wide rules of thumb
- One concept per file. If a file does two things, split it.
- Long files are a smell. Past ~300 lines, ask whether anything is trying to escape.
- Imports go at the top, side by side, in groups (third-party first, then local).
- A module shouldn't reach across directories to grab another module's internals — only its public exports.
- If two modules keep needing to know about the same thing, extract that thing into a third module.
Challenge
Build a tiny budget tracker across two files.
In budget.js, implement and export:
addExpense(amount, category)— records an expense.addIncome(amount)— records income.balance()— returns income minus the sum of all expenses.expensesByCategory()— returns an object mapping each category to its total.
In index.js, require those functions and run a quick demo.
Rules:
- All four functions must be exported from
budget.js. - Internal storage (the array of records) MUST NOT be exported.
addExpenseshould throw if amount is not a positive number.balance()for a fresh module (no entries) returns0.
The tests will require ./budget and call the exported functions.
What's the main reason to keep a module's public surface (its exports) as small as possible?
Smaller exports make the program run faster
Files with fewer exports use less memory
Anything you export becomes a commitment — callers can rely on it, so the more you expose, the less freedom you have to change the internals later
Smaller exports look nicer in editors