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

  1. Why modules exist
  2. The CommonJS system: require and module.exports
  3. exports versus module.exports
  4. The module wrapper and its five variables
  5. Module types and require's resolution algorithm
  6. The module cache: a module is a singleton
  7. Circular dependencies
  8. Refactoring Escena Viva
  9. Good practices for module design

  1. 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 here

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

  1. The CommonJS system: require and module.exports

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

  1. It is extensible. Adding a second export breaks nobody.
  2. The name travels with the value. const { SalesManager } = require(...) makes it clear what it is, whereas const X = require(...) depends on the importer picking a good name.
  3. It is consistent with ES modules, which we will see in the next lesson.

  1. exports versus module.exports

Here 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 object
flowchart 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:

// WRONG: this exports nothing.
exports = { formatPrice, formatDate };
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);    // undefined

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

  1. 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:

const catalog = [];
module.exports = { catalog };

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, module and exports come from: they are parameters of that function, not global variables.
  • Why this at the top level of a CommonJS module is module.exports (an empty object), and not global.

You can see it with your own eyes:

// src/lab/wrapper.js
console.log(require('node:module').wrapper);
[
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
]

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.

  1. Module types and require's resolution algorithm

When 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 of require('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.json

That 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'), not require('./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/express

You can see that list in real time:

console.log(module.paths);

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);   // 3

It is convenient and you used it in the node -p commands of Module 1. But it has three drawbacks you should know about:

  1. It is synchronous: it blocks the main thread while reading the file.
  2. It is cached: if the file changes on disk, require keeps returning the old version.
  3. 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.

  1. 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 its module.exports is 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:

  1. The evaluation message appears only once, even though there were three requires.
  2. All three objects are identical (===).
  3. 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 application

The remedy: freeze whatever must be immutable.

module.exports = Object.freeze({ ticketLimitPerOrder: 10 });

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 = 0

Manipulating require.cache is 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.

  1. 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');
node src/lab/circular-a.js
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 evaluated

The 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 function

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

  1. 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.js
flowchart 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:

  1. 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 with structuredClone protects the source.
  2. The signature will be the same when the real data arrives. In Module 3, getCatalog() will read data/events.json with fs and become asynchronous. No consumer will have to change its structure, only add an await. 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:

node src/catalog.js --table
┌─────────┬───────────┬─────────────────────────────────┬────────────────────┬─────────────┬─────────────┬───────────┬───────────┐
│ (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.

  1. 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 modules

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

  1. Depends only on ../domain and ../utils/format.js (not on catalog-data.js: the data is passed to it).
  2. 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.
  3. Is directly runnable with node src/reports/occupancy.js thanks to require.main === module, loading the catalog and showing the three reports with console.table.
  4. 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:

  1. A src/lab/sales-log.js module that keeps a private array of sales, exports record(sale), total() and list(), and prints a message when it is evaluated.
  2. 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.
  3. That afterwards clears the cache with delete require.cache[require.resolve(...)], requires it again and demonstrates that the state has been lost.
  4. That prints how many modules are in require.cache before 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 };
node src/reports/occupancy.js
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}`);
node src/lab/test-reports.js
# Sold out: 0

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.cache in 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');
node src/lab/circular-order.js
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

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved