We have been building up a debt for five lessons. In every lab file you have copied the catalog array again. The Event and Session classes you wrote in Module 1 still have no home. You have written module.exports three times without anyone explaining what it does. And in the Your First Node.js Program lesson we left a note that said, literally, "the fact that this duplication makes you uncomfortable is precisely why modules exist".
Today we settle that debt. You are going to understand the CommonJS module system: how require finds files, what exactly module.exports does, why reassigning exports does not work, what invisible wrapper Node adds to every file it runs, and why a module is evaluated exactly once in the whole life of the process, becoming a de facto singleton.
And by the end, Escena Viva will finally have a real structure: src/catalog-data.js exporting the catalog, src/domain/event.js and src/domain/session.js with the Module 1 classes, a src/domain/index.js that re-exports them, and src/catalog.js consuming it all without a single duplicated line.
Contents
- Why modules exist
- The CommonJS system:
requireandmodule.exports exportsversusmodule.exports- The module wrapper and its five variables
- Module types and
require's resolution algorithm - The module cache: a module is a singleton
- Circular dependencies
- Refactoring Escena Viva
- Good practices for module design
- Why modules exist
JavaScript was born without modules. For fifteen years, in the browser, every script on a page shared a single global scope, with the predictable consequences:
<!-- The problem of the shared global scope -->
<script src="catalog.js"></script> <!-- defines: var catalog = [...] -->
<script src="reports.js"></script> <!-- also defines: var catalog = {} -->
<!-- The second overwrites the first. Nobody warns you. Everything breaks. -->Three concrete problems:
| Problem | Consequence |
|---|---|
| Name collisions | Two files with the same global variable silently overwrite each other |
| Implicit dependencies | The order of the <script> tags matters, but it is written down nowhere |
| Nothing is private | Any internal detail is accessible and modifiable from anywhere |
Node.js could not afford that: a server with a hundred files and thirty external dependencies would have been unmanageable. So it adopted CommonJS, a module specification designed for the server side, and shipped it from its very first version.
The central idea is radically simple:
Every file is a module. Everything you declare inside is private, except what you explicitly export.
// src/utils/format.js
// PRIVATE: nobody outside this file can see this constant.
const CURRENCY_SYMBOL = 'EUR';
// PRIVATE: an internal helper function.
function splitCents(cents) {
return { euros: Math.floor(cents / 100), remainder: cents % 100 };
}
// PUBLIC: only this leaves the module.
function formatPrice(cents) {
const { euros, remainder } = splitCents(cents);
return `${euros}.${String(remainder).padStart(2, '0')} ${CURRENCY_SYMBOL}`;
}
module.exports = { formatPrice };// Another file
const { formatPrice } = require('./utils/format.js');
console.log(formatPrice(2500)); // 25.00 EUR
console.log(CURRENCY_SYMBOL); // ReferenceError: does not exist here
console.log(splitCents); // ReferenceError: does not exist hereThat privacy-by-default is the system's most valuable property. It lets you change a module's internal details without fear, because you know for certain that nobody outside depends on them.
- The CommonJS system:
require and module.exports
require and module.exportsCommonJS has only two verbs.
module.exports: what the module offers
Every module has a module object, and its exports property is exactly the value require will return. You can assign it anything you like:
// An object with several things (the most common case)
module.exports = { formatPrice, formatDate };
// A single function
module.exports = function calculateOccupancy(session) { /* ... */ };
// A single class
module.exports = class Event { /* ... */ };
// An array of data
module.exports = [ { id: 'evt-001' }, { id: 'evt-002' } ];
// A primitive value (rare, but legal)
module.exports = 3000;require: what the module needs
require(path) loads a module and returns its module.exports:
// Import the whole object
const format = require('./utils/format.js');
console.log(format.formatPrice(2500));
// Destructure only what you need (preferred: you can see at a glance
// what this file uses)
const { formatPrice } = require('./utils/format.js');
// Rename while destructuring, to avoid collisions
const { formatPrice: price } = require('./utils/format.js');The two export styles
| Style | When to use it | Import example |
|---|---|---|
Named object module.exports = { a, b } |
The module offers several related things | const { a, b } = require('./m.js') |
Single export module.exports = X |
The module is one single thing: a class, a function | const X = require('./m.js') |
In Escena Viva we will use the named object almost always, even for modules with a single item. The reasons:
- It is extensible. Adding a second export breaks nobody.
- The name travels with the value.
const { SalesManager } = require(...)makes it clear what it is, whereasconst X = require(...)depends on the importer picking a good name. - It is consistent with ES modules, which we will see in the next lesson.
exports versus module.exports
exports versus module.exportsHere is CommonJS's classic trap, and it is worth understanding thoroughly because its explanation reveals how the system works.
Node injects two related variables into every module: module and exports. And at the start of the file, this holds:
// What Node does, conceptually, before running your code:
const module = { exports: {} };
let exports = module.exports; // <-- Both point at the SAME objectflowchart LR
subgraph start["At the start of the module"]
E1["exports"] --> O1["{ }<br/><i>the export object</i>"]
M1["module.exports"] --> O1
end
As long as you add properties, both variables behave the same, because they manipulate the same object:
// src/utils/format.js
exports.formatPrice = formatPrice; // Works
exports.formatDate = formatDate; // Works
// Equivalent:
module.exports.formatPrice = formatPrice;But the moment you reassign exports, the connection breaks:
flowchart LR
subgraph broken["After reassigning exports"]
E2["exports"] --> O3["{ formatPrice,<br/>formatDate }<br/><i>a new, orphaned object</i>"]
M2["module.exports"] --> O2["{ }<br/><i>the object require returns</i>"]
end
style O3 stroke-dasharray: 5 5
exports now points at a new object, but module.exports still points at the original empty one. And require returns module.exports, not exports.
Check it:
// src/lab/broken-exports.js
exports = { hello: () => 'hello' };
console.log('Inside the module, exports:', exports); // { hello: [Function] }
console.log('Inside the module, module.exports:', module.exports); // {}// src/lab/test-exports.js
const loaded = require('./broken-exports.js');
console.log(loaded); // {} <- empty
console.log(loaded.hello); // undefinedThe rule, in a table:
| What you write | Does it work? | Why |
|---|---|---|
exports.name = value |
Yes | Adds a property to the shared object |
module.exports.name = value |
Yes | Identical to the above |
module.exports = { ... } |
Yes | Replaces what require will return |
exports = { ... } |
No | Breaks the connection; module.exports does not change |
module.exports = X and then exports.y = ... |
Not for y |
They are already different objects |
Recommendation for the course: always use
module.exports = { ... }on a single line at the end of the file.You will never hit this problem, and the file gains something valuable as well: a single place where its whole public API is visible. A reader opening the module can jump to the end and know in two seconds what it offers.
- The module wrapper and its five variables
How does Node give each file its own scope, if JavaScript has no modules? With a remarkably elegant trick: before running your file, it wraps it in a function.
Your code:
What Node actually runs:
(function (exports, require, module, __filename, __dirname) {
// ---- Your code goes here, untouched ----
const catalog = [];
module.exports = { catalog };
// ----------------------------------------
});This is called the module wrapper, and it explains several things at once:
- Why your variables do not pollute the global scope: they are inside a function.
- Where
require,moduleandexportscome from: they are parameters of that function, not global variables. - Why
thisat the top level of a CommonJS module ismodule.exports(an empty object), and notglobal.
You can see it with your own eyes:
The five injected variables:
| Variable | What it is | Example use |
|---|---|---|
exports |
A shortcut to module.exports (with the trap from section 3) |
exports.format = fn |
require |
A function for loading other modules | require('./format.js') |
module |
The object representing this module | module.exports = {...} |
__filename |
The absolute path of this file | /home/joan/escena-viva/src/catalog.js |
__dirname |
The absolute path of the folder containing it | /home/joan/escena-viva/src |
// src/lab/module-variables.js
console.log('__filename:', __filename);
console.log('__dirname :', __dirname);
console.log('module.id :', module.id); // '.' if it is the entry point
console.log('this === module.exports:', this === module.exports); // true
console.log('Is the entry point:', require.main === module);Two practical uses you will see a lot:
__dirname for reliable paths. The working directory (process.cwd()) depends on where the command is run from; __dirname does not.
const path = require('node:path');
// WRONG: it depends on where you run node from.
const dataPath = './data/events.json';
// RIGHT: always correct, wherever you run it from.
const dataPath = path.join(__dirname, '..', 'data', 'events.json');We will formalize this in the Cross-Platform Paths with the path Module lesson.
require.main === module to know whether you are the main program. It lets a file be both a reusable module and an executable script:
// src/reports/occupancy.js
function generateOccupancyReport(catalog) {
// ... report logic ...
}
// Only if it is run directly with "node src/reports/occupancy.js"
if (require.main === module) {
const { catalog } = require('../catalog-data.js');
console.log(generateOccupancyReport(catalog));
}
module.exports = { generateOccupancyReport };With this, require('./reports/occupancy.js') prints nothing (it only exports the function), but node src/reports/occupancy.js does run the report. It is a very useful and very common pattern.
- Module types and
require's resolution algorithm
require's resolution algorithmWhen you write require('something'), Node has to decide which file to load. It follows a well-defined algorithm.
The three module types
| Type | How it is written | Example | Where it lives |
|---|---|---|---|
| Core | A bare name, or with the node: prefix |
require('node:fs') |
Inside the Node binary |
| File | A path starting with ./, ../ or / |
require('./domain/event.js') |
Your project |
| Package | A bare name that is not a core module | require('express') |
node_modules/ |
Always use the
node:prefix for core modules:require('node:fs')instead ofrequire('fs'). It is the modern, recommended form, it removes any ambiguity with an npm package of the same name, and it is slightly faster because Node skips the lookup.
The algorithm
flowchart TD
A["require('X')"] --> B{"Is X a core<br/>module?"}
B -->|"Yes"| C["Return the internal module<br/>(fs, path, http, events...)"]
B -->|"No"| D{"Does X start with<br/>./ , ../ or / ?"}
D -->|"Yes"| E["Resolve as a file:<br/>1. X as is<br/>2. X.js<br/>3. X.json<br/>4. X.node"]
E --> F{"Does it exist?"}
F -->|"Yes"| G["Load and return"]
F -->|"No"| H["Resolve as a folder:<br/>1. X/package.json → main field<br/>2. X/index.js<br/>3. X/index.json"]
H --> I{"Does it exist?"}
I -->|"Yes"| G
I -->|"No"| J["Error: MODULE_NOT_FOUND"]
D -->|"No"| K["Look in node_modules,<br/>walking up the folder tree"]
K --> L{"Found?"}
L -->|"Yes"| G
L -->|"No"| J
File and folder resolution
Node tries extensions and then the folder interpretation:
require('./domain/event')
// 1. ./domain/event (as is)
// 2. ./domain/event.js <-- normally here
// 3. ./domain/event.json
// 4. ./domain/event.node (binary C++ addon)
require('./domain')
// 1-4. The above with "domain"...
// 5. ./domain/package.json -> reads its "main" field
// 6. ./domain/index.js <-- the usual pattern
// 7. ./domain/index.jsonThat point 6 is the reason the index.js file convention exists: it lets require('./domain') load a whole folder. We will use it in the refactoring.
Even though extensions are optional in CommonJS, always write them:
require('./event.js'), notrequire('./event'). It is more explicit, it is slightly faster (Node does not have to try), and — above all — it is mandatory in ES modules, so writing them prepares you for the next lesson and for migrating without surprises.
The upward search in node_modules
For a package like express, Node looks in node_modules walking up folder by folder to the filesystem root:
From /home/joan/escena-viva/src/server/routes.js, require('express') looks in:
/home/joan/escena-viva/src/server/node_modules/express
/home/joan/escena-viva/src/node_modules/express
/home/joan/escena-viva/node_modules/express <-- normally here
/home/joan/node_modules/express
/home/node_modules/express
/node_modules/expressYou can see that list in real time:
This mechanism explains something that confuses people at first: why a package sometimes works without being in your package.json. It was installed in a parent folder and the upward search found it. It is an accidental dependency that will stop working as soon as you move the project or somebody else installs it from scratch. We will look at it in Module 5.
Loading JSON directly
CommonJS loads .json files natively, already parsed:
// Returns the array directly, with no JSON.parse.
const catalog = require('./data/events.json');
console.log(catalog.length); // 3It is convenient and you used it in the node -p commands of Module 1. But it has three drawbacks you should know about:
- It is synchronous: it blocks the main thread while reading the file.
- It is cached: if the file changes on disk,
requirekeeps returning the old version. - It does not exist in ES modules without extra syntax.
For startup configuration it is perfectly acceptable. For data that changes — such as the Escena Viva catalog — we will use fs in Module 3.
- The module cache: a module is a singleton
This is the CommonJS characteristic with the most practical consequences:
A module is evaluated ONLY ONCE. The first time it is
required, it runs and itsmodule.exportsis stored in the cache. Every later call returns exactly the same object.
Let's demonstrate it with a counter:
// src/lab/counter.js
console.log('>> the counter.js module is being EVALUATED');
let count = 0;
function increment() {
count++;
return count;
}
function value() {
return count;
}
module.exports = { increment, value };// src/lab/use-counter.js
console.log('--- First require ---');
const first = require('./counter.js');
console.log('--- Second require ---');
const second = require('./counter.js');
console.log('--- Third require ---');
const third = require('./counter.js');
console.log('');
console.log('Are they the same object?', first === second, second === third);
first.increment();
first.increment();
second.increment();
console.log('Value seen from "first":', first.value());
console.log('Value seen from "third":', third.value());--- First require --- >> the counter.js module is being EVALUATED --- Second require --- --- Third require --- Are they the same object? true true Value seen from "first": 3 Value seen from "third": 3
Three facts in that output:
- The evaluation message appears only once, even though there were three
requires. - All three objects are identical (
===). - State is shared: incrementing through one reference is seen by everybody.
In other words: every CommonJS module is a singleton within the process.
The usefulness
It is exactly what you want for shared, expensive-to-create resources:
// src/data/connection.js
// The database connection pool: one single pool in the whole process.
const pool = createConnectionPool({ max: 20 });
module.exports = { pool };It does not matter how many files require it: it is always the same pool of 20 connections, not twenty pools. The same applies to Escena Viva's SalesManager, to a logger or to an in-memory cache. This pattern is what we will use in Module 7.
The risks
Risk 1: unwanted shared state.
// DANGER: the configuration is mutable and global.
// src/config.js
module.exports = { ticketLimitPerOrder: 10 };
// In another file, somebody does:
const config = require('./config.js');
config.ticketLimitPerOrder = 999; // Changes it for the WHOLE applicationThe remedy: freeze whatever must be immutable.
Risk 2: tests contaminating each other.
If a module accumulates state and several tests use it, the second test inherits the first one's state. It is a classic source of tests that pass alone and fail together. We will deal with it in Module 9; for stateful modules, the usual solution is to export a factory function instead of an instance:
// Instead of exporting an instance (a forced singleton)...
module.exports = { manager: new SalesManager(catalog) };
// ...export the class and let each caller create its own.
module.exports = { SalesManager };Risk 3: the cache key is the resolved path. Two different paths pointing at the same file (through a symbolic link, or through letter case on Windows) can produce two instances of the same module. It is rare, but when it happens it is baffling.
Inspecting and clearing the cache
// Paths of every loaded module
console.log(Object.keys(require.cache));
// Force a module to be re-evaluated on the next require
delete require.cache[require.resolve('./counter.js')];
const fresh = require('./counter.js'); // Re-evaluated: count = 0Manipulating
require.cacheis a laboratory tool, not a production one. It is used in some test setups and in development hot reloaders. In application code it is almost always a sign of a design problem.
- Circular dependencies
This happens when A requires B and B requires A. Node does not fail, but the result is surprising.
// src/lab/circular-a.js
console.log('A: starts being evaluated');
const b = require('./circular-b.js');
console.log('A: b is', b);
module.exports = { name: 'module A' };
console.log('A: finishes being evaluated');// src/lab/circular-b.js
console.log('B: starts being evaluated');
const a = require('./circular-a.js');
console.log('B: a is', a); // <-- here comes the surprise
module.exports = { name: 'module B' };
console.log('B: finishes being evaluated');A: starts being evaluated
B: starts being evaluated
B: a is {} <-- an EMPTY object, not { name: 'module A' }
B: finishes being evaluated
A: b is { name: 'module B' }
A: finishes being evaluatedThe step-by-step explanation:
| Step | What happens |
|---|---|
| 1 | A starts being evaluated. Node registers A in the cache with module.exports = {} (empty) |
| 2 | A does require('./circular-b.js'). B starts being evaluated |
| 3 | B does require('./circular-a.js'). A is already in the cache, so Node returns its current module.exports: the empty object |
| 4 | B finishes and exports its own things correctly |
| 5 | A receives B's complete module.exports and finishes |
The rule: in a circular dependency, the module loaded second receives an incomplete version of the first. It is not a Node bug: it is the only reasonable thing it can do without entering an infinite loop.
And that is why circular dependencies produce such baffling errors:
// In circular-b.js
const { createEvent } = require('./circular-a.js');
createEvent(); // TypeError: createEvent is not a functionHow to avoid them
| Technique | How |
|---|---|
| Extract the common part into a third module | If A and B share something, that something goes into C, and both depend on C |
| Invert the dependency | Instead of A looking for B, whoever uses both passes them what they need |
Move the require inside the function |
It resolves at run time, when both modules are already complete. It is a patch, not a solution |
| Use events | A emits and B listens, without A knowing B. Exactly the previous lesson |
That last row is no accident. Many circular dependencies are a symptom of excessive coupling, and the observer pattern from the Events and EventEmitter lesson is often the correct design solution, not just a trick to break the cycle.
To detect them in a real project there are tools such as madge, which draws the dependency graph and points out the cycles.
- Refactoring Escena Viva
The moment has come. We are going to leave the project the way it should be at the end of Module 2.
8.1 The starting point and the destination
BEFORE AFTER
escena-viva/ escena-viva/
├── data/ ├── data/
│ └── events.json │ └── events.json
└── src/ └── src/
├── catalog.js <- embedded ├── catalog.js <- presentation only
│ data ├── catalog-data.js <- data only
└── catalog-data.js <- no ├── domain/
module.exports │ ├── session.js
│ ├── event.js
│ ├── sales-manager.js
│ └── index.js
└── utils/
└── format.jsflowchart TD
C["src/catalog.js<br/><i>entry point</i>"]
CD["src/catalog-data.js<br/><i>the data</i>"]
DI["src/domain/index.js<br/><i>facade</i>"]
EV["src/domain/event.js"]
SE["src/domain/session.js"]
GV["src/domain/sales-manager.js"]
FO["src/utils/format.js"]
C --> CD
C --> DI
C --> FO
DI --> EV
DI --> SE
DI --> GV
EV --> SE
GV --> SE
Notice the shape of the graph: every arrow goes in one direction. There are no cycles, and dependencies go from the general (catalog.js) to the specific (session.js). That is the goal of all module design.
8.2 src/utils/format.js
We start at the bottom: the module that depends on nothing.
// src/utils/format.js
// Escena Viva presentation utilities.
// No dependencies: it is the most basic module in the project.
// Turns 2500 (cents) into the string '25.00 EUR'.
function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
// Turns '2026-10-03T20:00:00' into '03/10/2026 20:00'.
function formatDate(isoDate) {
const date = new Date(isoDate);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hour}:${minute}`;
}
// Generates a ticket code: EV-2026-000123
function generateTicketCode(year, sequence) {
return `EV-${year}-${String(sequence).padStart(6, '0')}`;
}
module.exports = { formatPrice, formatDate, generateTicketCode };Those three functions were duplicated across several Module 1 files. Now they exist exactly once.
8.3 src/domain/session.js
The Session class you wrote as an exercise solution in the Modern JavaScript for Node.js lesson, now with a home of its own.
// src/domain/session.js
// A session is a specific showing of an event, with its date, capacity and price.
const { formatPrice, formatDate } = require('../utils/format.js');
class Session {
// Private field: the only way to modify it is through sell().
#sold = 0;
constructor({ id, dateTime, capacity, sold = 0, priceCents }) {
this.id = id;
this.dateTime = dateTime;
this.capacity = capacity;
this.priceCents = priceCents;
this.#sold = sold;
}
get sold() {
return this.#sold;
}
get available() {
return this.capacity - this.#sold;
}
get occupancy() {
return Math.round((this.#sold / this.capacity) * 100);
}
get soldOut() {
return this.available === 0;
}
get priceEuros() {
return (this.priceCents / 100).toFixed(2);
}
// Revenue in cents, an integer.
get revenueCents() {
return this.#sold * this.priceCents;
}
sell(quantity = 1) {
if (!Number.isInteger(quantity) || quantity < 1) {
const error = new Error('The quantity must be a positive integer');
error.code = 'INVALID_QUANTITY';
throw error;
}
if (quantity > this.available) {
const error = new Error(`Insufficient capacity in ${this.id}: ${this.available} left`);
error.code = 'INSUFFICIENT_CAPACITY';
throw error;
}
this.#sold += quantity;
return this.#sold;
}
// A single-row line for the console listing.
describe() {
return (
`${formatDate(this.dateTime)} ${formatPrice(this.priceCents)} ` +
`${this.available}/${this.capacity} available (${this.occupancy}% occupied)` +
(this.soldOut ? ' [SOLD OUT]' : '')
);
}
// JSON.stringify automatically calls toJSON if it exists.
// Without this, the private #sold field would not appear in the serialization.
toJSON() {
return {
id: this.id,
dateTime: this.dateTime,
capacity: this.capacity,
sold: this.#sold,
priceCents: this.priceCents
};
}
}
module.exports = { Session };8.4 src/domain/event.js
// src/domain/event.js
// An event is a scheduled show, with one or more sessions.
const { Session } = require('./session.js');
class Event {
#sessions = [];
constructor({ id, title, venue, organizer, category, durationMinutes, status = 'published', sessions = [] }) {
this.id = id;
this.title = title;
this.venue = venue;
this.organizer = organizer;
this.category = category;
this.durationMinutes = durationMinutes;
this.status = status;
// We turn the plain objects into Session instances.
// If they already are, we leave them as they are.
this.#sessions = sessions.map((s) => (s instanceof Session ? s : new Session(s)));
}
get sessions() {
// Defensive copy: nobody outside can add or remove sessions.
return [...this.#sessions];
}
get sessionCount() {
return this.#sessions.length;
}
get totalCapacity() {
return this.#sessions.reduce((total, s) => total + s.capacity, 0);
}
get ticketsSold() {
return this.#sessions.reduce((total, s) => total + s.sold, 0);
}
get totalAvailableTickets() {
return this.totalCapacity - this.ticketsSold;
}
get occupancy() {
if (this.totalCapacity === 0) return 0;
return Math.round((this.ticketsSold / this.totalCapacity) * 100);
}
get revenueCents() {
return this.#sessions.reduce((total, s) => total + s.revenueCents, 0);
}
get soldOut() {
return this.#sessions.every((s) => s.soldOut);
}
findSession(sessionId) {
return this.#sessions.find((s) => s.id === sessionId);
}
availableTickets(sessionId) {
const session = this.findSession(sessionId);
return session ? session.available : 0;
}
reserve(sessionId, quantity = 1) {
const session = this.findSession(sessionId);
if (!session) {
const error = new Error(`Session ${sessionId} does not exist in ${this.id}`);
error.code = 'SESSION_NOT_FOUND';
throw error;
}
// We delegate to Session: it is the one that knows how to validate its own capacity.
return session.sell(quantity);
}
toString() {
return `${this.title} (${this.venue}) - ${this.occupancy}% occupied`;
}
toJSON() {
return {
id: this.id,
title: this.title,
venue: this.venue,
organizer: this.organizer,
category: this.category,
durationMinutes: this.durationMinutes,
status: this.status,
sessions: this.#sessions.map((s) => s.toJSON())
};
}
static fromJSON(object) {
return new Event(object);
}
}
module.exports = { Event };Notice the design improvement over the Module 1 version: Event.reserve no longer validates capacity by hand, it delegates to session.sell(). Each class knows how to validate its own concerns. This is only possible now that Session exists as an independent module and Event can require it.
8.5 src/domain/index.js: the facade
// src/domain/index.js
// The domain facade: a single entry point for the whole model.
// It lets you write require('./domain') instead of three separate requires.
const { Session } = require('./session.js');
const { Event } = require('./event.js');
const { SalesManager, LOW_CAPACITY_THRESHOLD } = require('./sales-manager.js');
module.exports = { Session, Event, SalesManager, LOW_CAPACITY_THRESHOLD };This pattern — an index.js that re-exports — is called a facade or barrel, and it brings two things:
// Without a facade: three lines, and the importer must know the internal structure.
const { Session } = require('./domain/session.js');
const { Event } = require('./domain/event.js');
const { SalesManager } = require('./domain/sales-manager.js');
// With a facade: one line, and the internal structure can change without breaking anything.
const { Session, Event, SalesManager } = require('./domain');The second advantage is the important one: if tomorrow you split event.js into two files, you only change the index.js. Nobody else notices.
8.6 src/catalog-data.js: at last, a module
Here is where the concrete Module 1 debt gets settled.
// src/catalog-data.js
// The data source for the Escena Viva catalog.
// Until module 3 the data is embedded here; afterwards it will be read
// from data/events.json with fs, and this file will be the only one that changes.
const catalog = [
{
id: 'evt-001',
title: 'Concierto de Otono',
venue: 'Teatro Almendra',
organizer: 'org-almendra',
category: 'concert',
durationMinutes: 95,
status: 'published',
sessions: [
{ id: 'ses-001-1', dateTime: '2026-10-03T20:00:00', capacity: 420, sold: 180, priceCents: 2500 },
{ id: 'ses-001-2', dateTime: '2026-10-04T19:00:00', capacity: 420, sold: 96, priceCents: 2200 }
]
},
{
id: 'evt-002',
title: 'Noche de Monologos',
venue: 'Sala Boveda',
organizer: 'org-boveda',
category: 'comedy',
durationMinutes: 80,
status: 'published',
sessions: [
{ id: 'ses-002-1', dateTime: '2026-10-10T21:30:00', capacity: 120, sold: 118, priceCents: 1800 },
{ id: 'ses-002-2', dateTime: '2026-10-11T21:30:00', capacity: 120, sold: 45, priceCents: 1800 },
{ id: 'ses-002-3', dateTime: '2026-10-17T21:30:00', capacity: 120, sold: 12, priceCents: 1500 }
]
},
{
id: 'evt-003',
title: 'Festival de Jazz de Primavera',
venue: 'Auditorio Ribera',
organizer: 'org-ribera',
category: 'festival',
durationMinutes: 240,
status: 'published',
sessions: [
{ id: 'ses-003-1', dateTime: '2027-04-17T19:00:00', capacity: 900, sold: 640, priceCents: 3800 },
{ id: 'ses-003-2', dateTime: '2027-04-18T19:00:00', capacity: 900, sold: 720, priceCents: 4200 }
]
}
];
// Returns a deep copy so nobody modifies the source by accident.
// structuredClone is native in Node since version 17.
function getCatalog() {
return structuredClone(catalog);
}
function getEventById(id) {
const event = catalog.find((e) => e.id === id);
return event ? structuredClone(event) : undefined;
}
module.exports = { getCatalog, getEventById };Two important decisions:
- We export functions, not the array directly. If we exported
module.exports = { catalog }, anybody could modify the source data and, because of the module cache, that modification would affect the whole process. Returning a copy withstructuredCloneprotects the source. - The signature will be the same when the real data arrives. In Module 3,
getCatalog()will readdata/events.jsonwithfsand become asynchronous. No consumer will have to change its structure, only add anawait. That is this layer's reason to exist.
8.7 src/catalog.js: the entry point
// src/catalog.js
// Entry point of Escena Viva's console catalog.
// Usage:
// node src/catalog.js
// node src/catalog.js --venue="Teatro Almendra"
// node src/catalog.js --table
// node src/catalog.js --max=2000
const { getCatalog } = require('./catalog-data.js');
const { Event } = require('./domain');
const { formatPrice } = require('./utils/format.js');
// --- Reading arguments ---
function readOptions(args) {
const options = { venue: null, table: false, maxPriceCents: Infinity };
for (const arg of args) {
if (arg === '--table') {
options.table = true;
} else if (arg.startsWith('--venue=')) {
options.venue = arg.slice('--venue='.length);
} else if (arg.startsWith('--max=')) {
options.maxPriceCents = Number(arg.slice('--max='.length));
}
}
return options;
}
// --- Presentation ---
function showDetail(events) {
console.log('');
console.log('==========================================');
console.log(' ESCENA VIVA - EVENT CATALOG');
console.log('==========================================');
for (const event of events) {
console.log('');
console.log(`${event.title} [${event.id}]`);
console.log(` Venue : ${event.venue}`);
console.log(` Category : ${event.category}`);
console.log(` Duration : ${event.durationMinutes} min`);
console.log(` Sessions : ${event.sessionCount}`);
console.log(` Available : ${event.totalAvailableTickets} tickets`);
console.log(` Occupancy : ${event.occupancy}%`);
for (const session of event.sessions) {
// The session knows how to describe itself: catalog.js computes nothing.
console.log(` - ${session.describe()}`);
}
}
console.log('');
}
function showTable(events) {
const rows = events.flatMap((event) =>
event.sessions.map((session) => ({
event: event.id,
title: event.title,
venue: event.venue,
session: session.id,
price: formatPrice(session.priceCents),
available: session.available,
occupancy: `${session.occupancy}%`
}))
);
console.table(rows);
}
// --- Main program ---
function main() {
const options = readOptions(process.argv.slice(2));
// The plain data is turned into domain objects.
let events = getCatalog().map((data) => Event.fromJSON(data));
if (options.venue) {
events = events.filter((event) => event.venue === options.venue);
}
if (options.maxPriceCents < Infinity) {
events = events.filter((event) =>
event.sessions.some((s) => s.priceCents <= options.maxPriceCents)
);
}
if (events.length === 0) {
// Diagnostics on stderr, per the project convention.
console.error('There are no events matching the given criteria.');
process.exitCode = 1;
return;
}
if (options.table) {
showTable(events);
} else {
showDetail(events);
}
const totalCapacity = events.reduce((t, e) => t + e.totalCapacity, 0);
const sold = events.reduce((t, e) => t + e.ticketsSold, 0);
const revenue = events.reduce((t, e) => t + e.revenueCents, 0);
console.error(
`${events.length} events | capacity ${totalCapacity} | sold ${sold} | ` +
`available ${totalCapacity - sold} | revenue ${formatPrice(revenue)}`
);
}
// It only runs if this file is the main program.
if (require.main === module) {
main();
}
module.exports = { readOptions, main };Checking it:
┌─────────┬───────────┬─────────────────────────────────┬────────────────────┬─────────────┬─────────────┬───────────┬───────────┐ │ (index) │ event │ title │ venue │ session │ price │ available │ occupancy │ ├─────────┼───────────┼─────────────────────────────────┼────────────────────┼─────────────┼─────────────┼───────────┼───────────┤ │ 0 │ 'evt-001' │ 'Concierto de Otono' │ 'Teatro Almendra' │ 'ses-001-1' │ '25.00 EUR' │ 240 │ '43%' │ │ 1 │ 'evt-001' │ 'Concierto de Otono' │ 'Teatro Almendra' │ 'ses-001-2' │ '22.00 EUR' │ 324 │ '23%' │ │ 2 │ 'evt-002' │ 'Noche de Monologos' │ 'Sala Boveda' │ 'ses-002-1' │ '18.00 EUR' │ 2 │ '98%' │ │ 3 │ 'evt-002' │ 'Noche de Monologos' │ 'Sala Boveda' │ 'ses-002-2' │ '18.00 EUR' │ 75 │ '38%' │ │ 4 │ 'evt-002' │ 'Noche de Monologos' │ 'Sala Boveda' │ 'ses-002-3' │ '15.00 EUR' │ 108 │ '10%' │ │ 5 │ 'evt-003' │ 'Festival de Jazz de Primavera' │ 'Auditorio Ribera' │ 'ses-003-1' │ '38.00 EUR' │ 260 │ '71%' │ │ 6 │ 'evt-003' │ 'Festival de Jazz de Primavera' │ 'Auditorio Ribera' │ 'ses-003-2' │ '42.00 EUR' │ 180 │ '80%' │ └─────────┴───────────┴─────────────────────────────────┴────────────────────┴─────────────┴─────────────┴───────────┴───────────┘ 3 events | capacity 3000 | sold 1811 | available 1189 | revenue 62298.00 EUR
The totals match the Module 1 seed data: 3000 capacity, 1811 sold, 1189 available. The refactoring has not changed a single figure.
And the important part: look at what is no longer in catalog.js. There is no data array, no duplicated formatPrice, no occupancy calculations. There is only argument reading and presentation. Each module does one thing.
- Good practices for module design
The rules we will follow throughout the course.
9.1 One responsibility per module
If you have to use "and" when describing a module, it is probably two:
| Bad | Good |
|---|---|
utils.js with formatting, validation, dates and calculations |
format.js, validation.js, dates.js |
event.js that also reads the data file |
event.js (model) + catalog-data.js (access) |
A utils.js that grows without limit is where everything nobody knows where to put ends up. When that happens, split it.
9.2 Export little
A module's public API is a commitment. Everything you export is something somebody can use and that, therefore, you will not be able to change without breaking someone else's code.
// WRONG: exposes internal details nobody outside needs.
module.exports = {
formatPrice,
splitCents, // Internal helper
CURRENCY_SYMBOL, // Internal constant
formatCache // Internal state
};
// RIGHT: only what is part of the contract.
module.exports = { formatPrice };Start by exporting the minimum. Growing an API is easy; shrinking it is not.
9.3 No side effects on load
A require should be cheap and safe: it defines things, it does not do them.
// WRONG: requiring this module opens a connection, reads a file
// and prints to the console. Without anyone asking for it.
const connection = connectToDatabase();
const data = fs.readFileSync('data/events.json');
console.log('Catalog module loaded');
module.exports = { connection, data };// RIGHT: the module defines capabilities. Whoever wants them invokes them.
function connect(options) { /* ... */ }
function loadData(path) { /* ... */ }
module.exports = { connect, loadData };A module with side effects is impossible to test in isolation, it makes startup slow and unpredictable, and — because of the cache — its effects happen once, at a moment you do not control.
The legitimate exception is the require.main === module pattern from section 4: effects only when the file is the program.
9.4 Put your requires at the top
// At the top of the file, grouped and ordered:
const path = require('node:path'); // 1. Node core
const express = require('express'); // 2. External packages
const { Event } = require('./domain'); // 3. Your own modulesThat way, anyone opening the file sees its dependencies in three seconds. A require hidden in the middle of a function is a dependency nobody is going to find.
9.5 Avoid conditional requires
// WRONG: the dependency is only discovered at run time.
if (process.env.MODE === 'production') {
logger = require('./production-logger.js');
}Load both and choose, or use a factory. The only reasonable exception is lazily loading a very heavy module that is rarely used, and even then it is worth documenting.
9.6 Prefer factories over instances when there is state
// Less flexible: it forces a singleton and complicates testing.
module.exports = { manager: new SalesManager(catalog) };
// More flexible: each caller creates its own, when and how it wants.
module.exports = { SalesManager };Export the instance only when the singleton is deliberate and desired: a connection pool, a global logger, a shared cache.
Common Mistakes and Tips
Mistake 1: exports = { ... } instead of module.exports = { ... }.
The module exports an empty object and the importer gets undefined for everything. Always use module.exports.
Mistake 2: forgetting the extension in relative paths.
It works in CommonJS, but it is ambiguous and it will not work in ES modules. Write ./session.js.
Mistake 3: using ./ for an npm package or the other way round.
require('express') looks in node_modules; require('./express') looks for a file. They are different things.
Mistake 4: believing every require creates a new instance.
It is a cached singleton. If you need independent instances, export the class or a factory.
Mistake 5: mutating an object exported by another module.
Since everybody shares the same object, the change affects the whole application. Object.freeze for configuration, and defensive copies for data.
Mistake 6: circular dependencies. One of the two modules will receive an incomplete object and the error will be incomprehensible. Extract the common part into a third module or use events.
Mistake 7: require inside a loop or a hot function.
The cache makes it cheap after the first time, but the cache lookup is not free. Put it at the top of the file.
Mistake 8: a utils.js that has everything.
It ends up being a module the whole project depends on and nobody dares touch.
Tip 1: a single module.exports at the end of the file. That is the index of your module's public API.
Tip 2: use node: for core modules. require('node:fs'), require('node:path'), require('node:events').
Tip 3: use __dirname to build paths. Never depend on process.cwd().
Tip 4: draw your project's dependency graph. If it has cycles, or if one module has fifteen incoming arrows, you know where the design problem is.
Exercises
Exercise 1: diagnosing a broken module
This module has five problems according to what you have learned. Find them, explain the consequence of each one and rewrite it.
// src/utils/helpers.js
const fs = require('fs');
console.log('Loading helpers...');
const catalog = JSON.parse(fs.readFileSync('./data/events.json', 'utf8'));
var CONFIG = { limit: 10 };
function formatPrice(c) {
return (c / 100).toFixed(2) + ' EUR';
}
function _round(n) {
return Math.round(n);
}
exports = { formatPrice, _round, CONFIG, catalog };Exercise 2: the reports module
Create src/reports/occupancy.js, a module that:
- Depends only on
../domainand../utils/format.js(not oncatalog-data.js: the data is passed to it). - Exports three functions:
summarizeByVenue(events)→ an array of{ venue, events, sessions, capacity, sold, available, occupancy, revenueEuros }.sessionsAtRisk(events, thresholdPercent)→ sessions below the occupancy threshold.soldOutSessions(events)→ sessions with no available tickets.
- Is directly runnable with
node src/reports/occupancy.jsthanks torequire.main === module, loading the catalog and showing the three reports withconsole.table. - When imported with
require, prints absolutely nothing.
Verify both behaviors: the direct run and a file that imports it and only calls soldOutSessions.
Exercise 3: module cache and circular dependency
Write two lab programs that experimentally demonstrate what you have learned:
Part A — src/lab/demonstrate-cache.js:
- A
src/lab/sales-log.jsmodule that keeps a private array of sales, exportsrecord(sale),total()andlist(), and prints a message when it is evaluated. - A program that requires it from three different points (the program itself and two helper modules), records sales from each one and demonstrates that the total is shared.
- That afterwards clears the cache with
delete require.cache[require.resolve(...)], requires it again and demonstrates that the state has been lost. - That prints how many modules are in
require.cachebefore and after.
Part B — src/lab/circular-*.js:
Create a real circular dependency between an order.js module and a ticket.js module (an order has tickets; a ticket knows its order), demonstrate the failure with a trace of messages, and then fix it with whichever technique you consider correct, justifying your choice.
Solutions
Solution 1
The five problems:
| # | Problem | Consequence |
|---|---|---|
| 1 | exports = { ... } instead of module.exports |
The module exports nothing. Whoever imports it gets {} |
| 2 | Side effect on load: it reads a file and does console.log |
Requiring the module blocks the thread and pollutes the output without anyone asking |
| 3 | Path relative to process.cwd() ('./data/events.json') |
It fails if run from another folder. It should use __dirname |
| 4 | Exports internal details (_round, CONFIG, catalog) |
It widens the public contract with things that should be private, and CONFIG is globally mutable |
| 5 | var and require('fs') without the node: prefix |
var has no block scope; the prefix avoids ambiguity with packages |
And a sixth as a bonus: the module mixes responsibilities (formatting, configuration and data access). It should be three modules.
The corrected version, split as it should be:
// src/utils/format.js
// Formatting only. No dependencies, no side effects.
function formatPrice(cents) {
const euros = Math.floor(cents / 100);
const remainder = String(cents % 100).padStart(2, '0');
return `${euros}.${remainder} EUR`;
}
module.exports = { formatPrice };// src/config.js
// Application configuration. Frozen so that nobody can modify it.
module.exports = Object.freeze({
ticketLimitPerOrder: 10,
lowCapacityThreshold: 0.10
});// src/catalog-data.js
// Data access. The read happens when it is REQUESTED, not on load.
const fs = require('node:fs');
const path = require('node:path');
// A path relative TO THE FILE, not to the working directory.
const DATA_PATH = path.join(__dirname, '..', 'data', 'events.json');
function getCatalog() {
const content = fs.readFileSync(DATA_PATH, 'utf8');
return JSON.parse(content);
}
module.exports = { getCatalog };(The asynchronous version of that read arrives in Module 3; what matters here is that the read is inside a function.)
Solution 2
// src/reports/occupancy.js
// Escena Viva occupancy reports.
// It receives the events as an argument: it knows nothing about their origin.
const { formatPrice } = require('../utils/format.js');
// Aggregated summary by venue.
function summarizeByVenue(events) {
const byVenue = new Map();
for (const event of events) {
const accumulated = byVenue.get(event.venue) ?? {
venue: event.venue, events: 0, sessions: 0,
capacity: 0, sold: 0, revenueCents: 0
};
accumulated.events += 1;
accumulated.sessions += event.sessionCount;
accumulated.capacity += event.totalCapacity;
accumulated.sold += event.ticketsSold;
accumulated.revenueCents += event.revenueCents;
byVenue.set(event.venue, accumulated);
}
return [...byVenue.values()].map((a) => ({
venue: a.venue,
events: a.events,
sessions: a.sessions,
capacity: a.capacity,
sold: a.sold,
available: a.capacity - a.sold,
occupancy: `${Math.round((a.sold / a.capacity) * 100)}%`,
revenueEuros: formatPrice(a.revenueCents)
}));
}
// Sessions below the occupancy threshold.
function sessionsAtRisk(events, thresholdPercent = 20) {
return events.flatMap((event) =>
event.sessions
.filter((session) => session.occupancy < thresholdPercent)
.map((session) => ({
event: event.id,
title: event.title,
session: session.id,
available: session.available,
occupancy: `${session.occupancy}%`
}))
);
}
// Sessions with no tickets available.
function soldOutSessions(events) {
return events.flatMap((event) =>
event.sessions
.filter((session) => session.soldOut)
.map((session) => ({
event: event.id,
title: event.title,
session: session.id,
capacity: session.capacity,
revenueEuros: formatPrice(session.revenueCents)
}))
);
}
// --- Direct run: only if this file IS the main program ---
if (require.main === module) {
const { getCatalog } = require('../catalog-data.js');
const { Event } = require('../domain');
const events = getCatalog().map((data) => Event.fromJSON(data));
const threshold = Number(process.argv[2]) || 20;
console.log('OCCUPANCY BY VENUE');
console.table(summarizeByVenue(events));
console.log('');
console.log(`SESSIONS AT RISK (below ${threshold}%)`);
const atRisk = sessionsAtRisk(events, threshold);
if (atRisk.length === 0) {
console.log(' None.');
} else {
console.table(atRisk);
process.exitCode = 1;
}
console.log('');
console.log('SOLD-OUT SESSIONS');
const soldOut = soldOutSessions(events);
if (soldOut.length === 0) {
console.log(' None.');
} else {
console.table(soldOut);
}
}
module.exports = { summarizeByVenue, sessionsAtRisk, soldOutSessions };OCCUPANCY BY VENUE ┌─────────┬────────────────────┬────────┬──────────┬──────────┬──────┬───────────┬───────────┬────────────────┐ │ (index) │ venue │ events │ sessions │ capacity │ sold │ available │ occupancy │ revenueEuros │ ├─────────┼────────────────────┼────────┼──────────┼──────────┼──────┼───────────┼───────────┼────────────────┤ │ 0 │ 'Teatro Almendra' │ 1 │ 2 │ 840 │ 276 │ 564 │ '33%' │ '6612.00 EUR' │ │ 1 │ 'Sala Boveda' │ 1 │ 3 │ 360 │ 175 │ 185 │ '49%' │ '3114.00 EUR' │ │ 2 │ 'Auditorio Ribera' │ 1 │ 2 │ 1800 │ 1360 │ 440 │ '76%' │ '52572.00 EUR' │ └─────────┴────────────────────┴────────┴──────────┴──────────┴──────┴───────────┴───────────┴────────────────┘ SESSIONS AT RISK (below 20%) ┌─────────┬───────────┬──────────────────────┬─────────────┬───────────┬───────────┐ │ (index) │ event │ title │ session │ available │ occupancy │ ├─────────┼───────────┼──────────────────────┼─────────────┼───────────┼───────────┤ │ 0 │ 'evt-002' │ 'Noche de Monologos' │ 'ses-002-3' │ 108 │ '10%' │ └─────────┴───────────┴──────────────────────┴─────────────┴───────────┴───────────┘ SOLD-OUT SESSIONS None.
And the check that importing it prints nothing:
// src/lab/test-reports.js
const { soldOutSessions } = require('../reports/occupancy.js');
const { getCatalog } = require('../catalog-data.js');
const { Event } = require('../domain');
const events = getCatalog().map((d) => Event.fromJSON(d));
console.log(`Sold out: ${soldOutSessions(events).length}`);Only that line. Neither the tables nor the headings appear, because require.main !== module. That is the difference between a well-designed module and one with side effects.
Solution 3, part A
// src/lab/sales-log.js
console.error('>> sales-log.js EVALUATING (this must be seen only once)');
const sales = [];
function record(sale) {
sales.push({ ...sale, recordedAt: new Date().toISOString() });
return sales.length;
}
function total() {
return sales.reduce((t, s) => t + s.amountCents, 0);
}
function list() {
return [...sales];
}
module.exports = { record, total, list };// src/lab/box-office-a.js
const log = require('./sales-log.js');
function sellFromBoxOfficeA() {
log.record({ sessionId: 'ses-001-1', quantity: 2, amountCents: 5000 });
}
module.exports = { sellFromBoxOfficeA };// src/lab/box-office-b.js
const log = require('./sales-log.js');
function sellFromBoxOfficeB() {
log.record({ sessionId: 'ses-002-2', quantity: 3, amountCents: 5400 });
}
module.exports = { sellFromBoxOfficeB };// src/lab/demonstrate-cache.js
console.error(`Modules in the cache at the start: ${Object.keys(require.cache).length}`);
const log = require('./sales-log.js');
const { sellFromBoxOfficeA } = require('./box-office-a.js');
const { sellFromBoxOfficeB } = require('./box-office-b.js');
console.error(`Modules in the cache after the requires: ${Object.keys(require.cache).length}`);
console.error('');
// Sales from three different points in the program.
log.record({ sessionId: 'ses-003-1', quantity: 1, amountCents: 3800 });
sellFromBoxOfficeA();
sellFromBoxOfficeB();
console.log('--- Shared state ---');
console.log(`Sales recorded: ${log.list().length}`);
console.log(`Total: ${(log.total() / 100).toFixed(2)} EUR`);
// Identity check.
const otherReference = require('./sales-log.js');
console.log(`Same object? ${log === otherReference}`);
console.log(`Total seen from the other reference: ${(otherReference.total() / 100).toFixed(2)} EUR`);
// --- Clearing the cache ---
console.log('');
console.log('--- After clearing the cache ---');
const path = require.resolve('./sales-log.js');
delete require.cache[path];
const newLog = require('./sales-log.js'); // Re-evaluated
console.log(`Same object as before? ${log === newLog}`);
console.log(`Sales in the new instance: ${newLog.list().length}`);
console.log(`Sales in the old instance: ${log.list().length}`);
console.error('');
console.error(`Modules in the cache at the end: ${Object.keys(require.cache).length}`);Modules in the cache at the start: 1 >> sales-log.js EVALUATING (this must be seen only once) Modules in the cache after the requires: 4 --- Shared state --- Sales recorded: 3 Total: 142.00 EUR Same object? true Total seen from the other reference: 142.00 EUR --- After clearing the cache --- >> sales-log.js EVALUATING (this must be seen only once) Same object as before? false Sales in the new instance: 0 Sales in the old instance: 3 Modules in the cache at the end: 4
Three conclusions you can read straight off the output:
- The evaluation message appears only once despite the four
requires, and the three sales recorded from different points add up in the same array. That is the singleton. - After clearing the cache, the module is re-evaluated and the new instance starts from zero, while the old one keeps its state. Two copies of the same module coexist, each with its own data.
- That last sentence is exactly why manipulating
require.cachein production is dangerous: you can end up with two "sales logs" and sales split between them without anyone noticing.
Solution 3, part B
The circular dependency:
// src/lab/circular-order.js
console.error('order.js: starts');
const { Ticket } = require('./circular-ticket.js');
console.error('order.js: Ticket is', typeof Ticket);
class Order {
constructor(id, sessionId, quantity) {
this.id = id;
this.tickets = [];
for (let i = 0; i < quantity; i++) {
this.tickets.push(new Ticket(`EV-2026-00000${i + 1}`, sessionId, this));
}
}
}
module.exports = { Order };
console.error('order.js: finishes');// src/lab/circular-ticket.js
console.error('ticket.js: starts');
const { Order } = require('./circular-order.js');
console.error('ticket.js: Order is', typeof Order); // <-- undefined
class Ticket {
constructor(code, sessionId, order) {
this.code = code;
this.sessionId = sessionId;
this.order = order;
this.status = 'valid';
}
// Uses Order to validate: it will fail if Order is undefined.
belongsToValidOrder() {
return this.order instanceof Order;
}
}
module.exports = { Ticket };
console.error('ticket.js: finishes');order.js: starts ticket.js: starts ticket.js: Order is undefined <-- the symptom ticket.js: finishes order.js: Ticket is function order.js: finishes
And when using it: TypeError: Right-hand side of 'instanceof' is not callable.
The chosen fix: invert the dependency.
// src/domain/ticket.js
// Ticket does NOT need to know the Order class: its identifier is enough.
class Ticket {
constructor({ code, sessionId, orderId, status = 'valid' }) {
this.code = code;
this.sessionId = sessionId;
this.orderId = orderId; // Only the id, not the object
this.status = status;
}
use() {
if (this.status !== 'valid') {
const error = new Error(`Ticket ${this.code} is ${this.status}`);
error.code = 'INVALID_TICKET';
throw error;
}
this.status = 'used';
return this;
}
cancel() {
this.status = 'cancelled';
return this;
}
}
module.exports = { Ticket };// src/domain/order.js
// Order knows Ticket. Ticket does not know Order. No cycle.
const { Ticket } = require('./ticket.js');
const { generateTicketCode } = require('../utils/format.js');
class Order {
#tickets = [];
constructor({ id, userId, sessionId, quantity, priceCents, status = 'pending' }) {
this.id = id;
this.userId = userId;
this.sessionId = sessionId;
this.quantity = quantity;
this.totalCents = quantity * priceCents;
this.status = status;
}
get tickets() {
return [...this.#tickets];
}
issue(year, startSequence) {
if (this.status !== 'paid') {
const error = new Error(`Tickets cannot be issued for a ${this.status} order`);
error.code = 'INVALID_STATE';
throw error;
}
for (let i = 0; i < this.quantity; i++) {
this.#tickets.push(new Ticket({
code: generateTicketCode(year, startSequence + i),
sessionId: this.sessionId,
orderId: this.id
}));
}
this.status = 'issued';
return this.tickets;
}
}
module.exports = { Order };Justifying the choice. Of the four techniques in section 7, inverting the dependency is the right one here because the cycle was a symptom of incorrect modeling, not a technical problem: a ticket does not need the whole Order object, only its identifier (orderId), which is exactly what the Module 1 domain model already defines. Moving the require inside the method would have worked, but it would have left the coupling intact and hidden; extracting into a third module would have added a file for no reason. The cycle disappeared because the design improved, which is always the best possible solution to a circular dependency.
Conclusion
You have settled the debt we had been carrying since the course's third lesson. You now know that every Node file is a module with its own scope, and that this privacy by default — the thing that lets you change internal details without fear — is achieved with a very concrete trick: the module wrapper, a function Node adds around your code and that injects five parameters into it: exports, require, module, __filename and __dirname.
You understand precisely the classic trap: exports and module.exports point at the same object at the start, so adding properties to either one works, but reassigning exports breaks the connection and leaves the module exporting an empty object, because require returns module.exports. Hence the rule we will always follow: a single module.exports = { ... } at the end of the file, which also works as an index of the public API.
You know the resolution algorithm: core modules first (better with the node: prefix), then relative or absolute paths with their cascade of extensions and their folder interpretation with index.js, and finally the upward search in node_modules. And you know a module is evaluated only once: it is a singleton cached by resolved path, which is perfect for a connection pool or a logger, and dangerous for mutable configuration or for tests that contaminate each other. You also know what Node returns for a circular dependency — an incomplete object to the second module — and that the solution is almost always about design, not syntax.
And Escena Viva has stopped being a pile of files with copied data. It now has src/utils/format.js with the presentation functions that were duplicated, src/domain/session.js and src/domain/event.js with the Module 1 classes — and with Event delegating capacity validation to Session, which is the one that knows how to do it — src/domain/sales-manager.js from the previous lesson, src/domain/index.js as a facade, src/catalog-data.js with module.exports and a signature ready to become asynchronous in Module 3, and a src/catalog.js that now only reads arguments and presents. The totals still add up: 3000 capacity, 1811 sold, 1189 available.
One last piece of the module remains, and it is one that changes the landscape. Everything you learned today — require, module.exports, the cache, the wrapper — is CommonJS, Node's own system. But JavaScript eventually got a standard module system, defined in the language and shared with the browser: import and export. It is not an alternative syntax for the same thing: it is a static model, resolved before anything runs, with different rules about extensions, no __dirname, no require… and with top-level await. In the next lesson, ES Modules and Interoperability, you will see both directions of the coexistence between the two systems, their real limits, and you will write the ESM version of the Escena Viva domain side by side with the one you have just finished.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
