Dataslope logoDataslope

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:

  1. CommonJS (CJS) — older, used by Node.js by default for years. Uses require() and module.exports.
  2. ES Modules (ESM) — the official language standard, used by browsers and now widely by Node. Uses import and export.

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

CommonJSES Modules
const x = require("./mod")import x from "./mod.js"
const { a, b } = require("./mod")import { a, b } from "./mod.js"
module.exports = somethingexport 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.

Code Block
JavaScript ES2023+

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.js

As 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 files

Better (grouped by feature):

src/
  users/
    index.js
    api.js
    validation.js
  orders/
    index.js
    api.js
    pricing.js
  shared/
    http.js
    config.js

When 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:

  1. Shared utilities (lowest layer) — depended on by everything.
  2. Feature modules — depended on by entry points.
  3. 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.

Code Block
JavaScript ES2023+

A few things to notice:

  • users.js and orders.js are peers — neither imports the other.
  • Both depend on shared/store.js, which depends on nothing.
  • index.js is 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/api

Outside 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

Challenge
JavaScript ES2023+
Split into modules

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.
  • addExpense should throw if amount is not a positive number.
  • balance() for a fresh module (no entries) returns 0.

The tests will require ./budget and call the exported functions.


QuestionSelect one

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

On this page