Llevamos cinco lecciones acumulando una deuda. En cada fichero de laboratorio has vuelto a copiar el array catalogo. Las clases Evento y Sesion que escribiste en el Módulo 1 siguen sin tener casa. Has escrito module.exports tres veces sin que nadie te explicara qué hace. Y en la lección Tu Primer Programa en Node.js dejamos una nota que decía, literalmente, "que esa duplicación te resulte incómoda es precisamente el motivo por el que existen los módulos".

Hoy pagamos esa deuda. Vas a entender el sistema de módulos CommonJS: cómo require encuentra los ficheros, qué hace exactamente module.exports, por qué reasignar exports no funciona, qué envoltorio invisible añade Node a cada fichero que ejecutas, y por qué un módulo se evalúa una sola vez en toda la vida del proceso, convirtiéndose de facto en un singleton.

Y al terminar, Escena Viva tendrá por fin una estructura de verdad: src/catalogo-datos.js exportando el catálogo, src/dominio/evento.js y src/dominio/sesion.js con las clases del Módulo 1, un src/dominio/index.js que las reexporta, y src/catalogo.js consumiéndolo todo sin una sola línea duplicada.

Contenido

  1. Por qué existen los módulos
  2. El sistema CommonJS: require y module.exports
  3. exports frente a module.exports
  4. El envoltorio de módulo y sus cinco variables
  5. Tipos de módulo y algoritmo de resolución de require
  6. La caché de módulos: un módulo es un singleton
  7. Dependencias circulares
  8. Refactorización de Escena Viva
  9. Buenas prácticas de diseño de módulos

  1. Por qué existen los módulos

JavaScript nació sin módulos. Durante quince años, en el navegador, todos los scripts de una página compartían un único ámbito global, con las consecuencias previsibles:

<!-- El problema del ambito global compartido -->
<script src="catalogo.js"></script>   <!-- define: var catalogo = [...] -->
<script src="informes.js"></script>   <!-- tambien define: var catalogo = {} -->
<!-- El segundo pisa al primero. Nadie avisa. Todo se rompe. -->

Tres problemas concretos:

Problema Consecuencia
Colisiones de nombres Dos ficheros con la misma variable global se pisan silenciosamente
Dependencias implícitas El orden de los <script> importa, pero no está escrito en ninguna parte
Nada es privado Cualquier detalle interno es accesible y modificable desde cualquier sitio

Node.js no podía permitirse eso: un servidor con cien ficheros y treinta dependencias externas habría sido inmanejable. Así que adoptó CommonJS, una especificación de módulos pensada para el lado del servidor, y la implantó desde su primera versión.

La idea central es de una simplicidad radical:

Cada fichero es un módulo. Todo lo que declaras dentro es privado, salvo lo que exportes explícitamente.

// src/utiles/formato.js
// PRIVADO: nadie fuera de este fichero puede ver esta constante.
const SIMBOLO_MONEDA = 'EUR';

// PRIVADO: funcion auxiliar interna.
function partirCentimos(centimos) {
  return { euros: Math.floor(centimos / 100), resto: centimos % 100 };
}

// PUBLICO: solo esto sale del modulo.
function formatearPrecio(centimos) {
  const { euros, resto } = partirCentimos(centimos);
  return `${euros},${String(resto).padStart(2, '0')} ${SIMBOLO_MONEDA}`;
}

module.exports = { formatearPrecio };
// Otro fichero
const { formatearPrecio } = require('./utiles/formato.js');

console.log(formatearPrecio(2500));    // 25,00 EUR
console.log(SIMBOLO_MONEDA);           // ReferenceError: no existe aqui
console.log(partirCentimos);           // ReferenceError: no existe aqui

Esa privacidad por defecto es la propiedad más valiosa del sistema. Te permite cambiar los detalles internos de un módulo sin miedo, porque sabes con certeza que nadie fuera depende de ellos.

  1. El sistema CommonJS: require y module.exports

CommonJS tiene solo dos verbos.

module.exports: lo que el módulo ofrece

Cada módulo tiene un objeto module, y su propiedad exports es exactamente el valor que require devolverá. Puedes asignarle lo que quieras:

// Un objeto con varias cosas (lo mas habitual)
module.exports = { formatearPrecio, formatearFecha };

// Una sola funcion
module.exports = function calcularOcupacion(sesion) { /* ... */ };

// Una sola clase
module.exports = class Evento { /* ... */ };

// Un array de datos
module.exports = [ { id: 'evt-001' }, { id: 'evt-002' } ];

// Un valor primitivo (raro, pero legal)
module.exports = 3000;

require: lo que el módulo necesita

require(ruta) carga un módulo y devuelve su module.exports:

// Importar el objeto completo
const formato = require('./utiles/formato.js');
console.log(formato.formatearPrecio(2500));

// Desestructurar solo lo que necesitas (preferido: se ve de un vistazo
// que usa este fichero)
const { formatearPrecio } = require('./utiles/formato.js');

// Renombrar al desestructurar, para evitar colisiones
const { formatearPrecio: precio } = require('./utiles/formato.js');

Los dos estilos de exportación

Estilo Cuándo usarlo Ejemplo de importación
Objeto con nombres module.exports = { a, b } El módulo ofrece varias cosas relacionadas const { a, b } = require('./m.js')
Exportación única module.exports = X El módulo es una sola cosa: una clase, una función const X = require('./m.js')

En Escena Viva usaremos el objeto con nombres casi siempre, incluso para módulos con un único elemento. Razones:

  1. Es ampliable. Añadir una segunda exportación no rompe a nadie.
  2. El nombre viaja con el valor. const { GestorDeVentas } = require(...) deja claro qué es, mientras que const X = require(...) depende de que quien importa elija bien el nombre.
  3. Es coherente con los módulos ES, que veremos en la lección siguiente.

  1. exports frente a module.exports

Aquí está la trampa clásica de CommonJS, y merece entenderse a fondo porque su explicación revela cómo funciona el sistema.

Node inyecta en cada módulo dos variables relacionadas: module y exports. Y al principio del fichero, se cumple esto:

// Lo que Node hace, conceptualmente, antes de ejecutar tu codigo:
const module = { exports: {} };
let exports = module.exports;   // <-- Ambas apuntan al MISMO objeto
flowchart LR
    subgraph inicio["Al empezar el módulo"]
        E1["exports"] --> O1["{ }<br/><i>el objeto de exportación</i>"]
        M1["module.exports"] --> O1
    end

Mientras añadas propiedades, las dos variables funcionan igual, porque manipulan el mismo objeto:

// src/utiles/formato.js
exports.formatearPrecio = formatearPrecio;   // Funciona
exports.formatearFecha = formatearFecha;     // Funciona

// Equivalente:
module.exports.formatearPrecio = formatearPrecio;

Pero en el momento en que reasignas exports, la conexión se rompe:

// MAL: esto NO exporta nada.
exports = { formatearPrecio, formatearFecha };
flowchart LR
    subgraph roto["Tras reasignar exports"]
        E2["exports"] --> O3["{ formatearPrecio,<br/>formatearFecha }<br/><i>objeto nuevo, huérfano</i>"]
        M2["module.exports"] --> O2["{ }<br/><i>el objeto que require devuelve</i>"]
    end

    style O3 stroke-dasharray: 5 5

exports pasa a apuntar a un objeto nuevo, pero module.exports sigue apuntando al original vacío. Y require devuelve module.exports, no exports.

Compruébalo:

// src/laboratorio/exports-roto.js
exports = { hola: () => 'hola' };
console.log('Dentro del modulo, exports:', exports);              // { hola: [Function] }
console.log('Dentro del modulo, module.exports:', module.exports); // {}
// src/laboratorio/probar-exports.js
const modulo = require('./exports-roto.js');
console.log(modulo);          // {}   <- vacio
console.log(modulo.hola);     // undefined

La regla, en una tabla:

Escritura ¿Funciona? Por qué
exports.nombre = valor Sí Añade una propiedad al objeto compartido
module.exports.nombre = valor Sí Idéntico a lo anterior
module.exports = { ... } Sí Sustituye lo que require devolverá
exports = { ... } No Rompe la conexión; module.exports no cambia
module.exports = X y luego exports.y = ... No para y Ya son objetos distintos

Recomendación para el curso: usa siempre module.exports = { ... } en una sola línea al final del fichero.

Nunca tendrás este problema, y además el fichero gana algo valioso: un lugar único donde se ve toda su API pública. Un lector que abra el módulo puede ir al final y saber en dos segundos qué ofrece.

  1. El envoltorio de módulo y sus cinco variables

¿Cómo consigue Node que cada fichero tenga su propio ámbito, si JavaScript no tiene módulos? Con un truco de una elegancia notable: antes de ejecutar tu fichero, lo envuelve en una función.

Tu código:

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

Lo que Node ejecuta realmente:

(function (exports, require, module, __filename, __dirname) {
  // ---- Tu codigo va aqui, intacto ----
  const catalogo = [];
  module.exports = { catalogo };
  // ------------------------------------
});

Esto se llama el envoltorio de módulo (module wrapper), y explica de golpe varias cosas:

  • Por qué tus variables no contaminan el ámbito global: están dentro de una función.
  • De dónde salen require, module y exports: son parámetros de esa función, no variables globales.
  • Por qué this en el nivel superior de un módulo CommonJS es module.exports (un objeto vacío), y no global.

Puedes verlo con tus propios ojos:

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

Las cinco variables inyectadas:

Variable Qué es Ejemplo de uso
exports Atajo a module.exports (con la trampa del apartado 3) exports.formatear = fn
require Función para cargar otros módulos require('./formato.js')
module Objeto que representa este módulo module.exports = {...}
__filename Ruta absoluta de este fichero /home/joan/escena-viva/src/catalogo.js
__dirname Ruta absoluta de la carpeta que lo contiene /home/joan/escena-viva/src
// src/laboratorio/variables-modulo.js
console.log('__filename:', __filename);
console.log('__dirname :', __dirname);
console.log('module.id :', module.id);        // '.' si es el punto de entrada
console.log('this === module.exports:', this === module.exports);   // true
console.log('Es el punto de entrada:', require.main === module);

Dos usos prácticos que verás mucho:

__dirname para rutas fiables. El directorio de trabajo (process.cwd()) depende de desde dónde se ejecute el comando; __dirname no.

const path = require('node:path');

// MAL: depende de desde donde ejecutes node.
const rutaDatos = './datos/eventos.json';

// BIEN: siempre correcta, ejecutes desde donde ejecutes.
const rutaDatos = path.join(__dirname, '..', 'datos', 'eventos.json');

Esto lo formalizaremos en la lección Rutas Multiplataforma con el Módulo path.

require.main === module para saber si eres el programa principal. Permite que un fichero sea a la vez módulo reutilizable y script ejecutable:

// src/informes/ocupacion.js

function generarInformeOcupacion(catalogo) {
  // ... logica del informe ...
}

// Solo si se ejecuta directamente con "node src/informes/ocupacion.js"
if (require.main === module) {
  const { catalogo } = require('../catalogo-datos.js');
  console.log(generarInformeOcupacion(catalogo));
}

module.exports = { generarInformeOcupacion };

Con esto, require('./informes/ocupacion.js') no imprime nada (solo exporta la función), pero node src/informes/ocupacion.js sí ejecuta el informe. Es un patrón muy útil y muy común.

  1. Tipos de módulo y algoritmo de resolución de require

Cuando escribes require('algo'), Node tiene que decidir qué fichero cargar. Sigue un algoritmo bien definido.

Los tres tipos de módulo

Tipo Cómo se escribe Ejemplo Dónde vive
Del núcleo Nombre a secas, o con prefijo node: require('node:fs') Dentro del binario de Node
De fichero Ruta que empieza por ./, ../ o / require('./dominio/evento.js') Tu proyecto
De paquete Nombre a secas que no es del núcleo require('express') node_modules/

Usa siempre el prefijo node: para los módulos del núcleo: require('node:fs') en lugar de require('fs'). Es la forma moderna y recomendada, elimina cualquier ambigüedad con un paquete de npm que se llamara igual, y es ligeramente más rápida porque Node se salta la búsqueda.

El algoritmo

flowchart TD
    A["require('X')"] --> B{"¿X es un módulo<br/>del núcleo?"}
    B -->|"Sí"| C["Devolver el módulo interno<br/>(fs, path, http, events...)"]
    B -->|"No"| D{"¿X empieza por<br/>./ , ../ o / ?"}

    D -->|"Sí"| E["Resolver como fichero:<br/>1. X tal cual<br/>2. X.js<br/>3. X.json<br/>4. X.node"]
    E --> F{"¿Existe?"}
    F -->|"Sí"| G["Cargar y devolver"]
    F -->|"No"| H["Resolver como carpeta:<br/>1. X/package.json → campo main<br/>2. X/index.js<br/>3. X/index.json"]
    H --> I{"¿Existe?"}
    I -->|"Sí"| G
    I -->|"No"| J["Error: MODULE_NOT_FOUND"]

    D -->|"No"| K["Buscar en node_modules,<br/>subiendo por el árbol de carpetas"]
    K --> L{"¿Encontrado?"}
    L -->|"Sí"| G
    L -->|"No"| J

Resolución de ficheros y carpetas

Node prueba extensiones y luego la interpretación como carpeta:

require('./dominio/evento')
// 1. ./dominio/evento          (tal cual)
// 2. ./dominio/evento.js       <-- normalmente aqui
// 3. ./dominio/evento.json
// 4. ./dominio/evento.node     (complemento binario en C++)

require('./dominio')
// 1-4. Lo anterior con "dominio"...
// 5. ./dominio/package.json    -> lee su campo "main"
// 6. ./dominio/index.js        <-- el patron habitual
// 7. ./dominio/index.json

Ese punto 6 es la razón de que exista la convención del fichero index.js: permite que require('./dominio') cargue toda una carpeta. Lo usaremos en la refactorización.

Aunque las extensiones sean opcionales en CommonJS, escríbelas siempre: require('./evento.js'), no require('./evento'). Es más explícito, es un poco más rápido (Node no tiene que probar), y —sobre todo— es obligatorio en módulos ES, así que escribirlas te prepara para la lección siguiente y para migrar sin sorpresas.

La búsqueda ascendente en node_modules

Para un paquete como express, Node busca en node_modules subiendo carpeta a carpeta hasta la raíz del sistema:

Desde /home/joan/escena-viva/src/servidor/rutas.js, require('express') busca en:

/home/joan/escena-viva/src/servidor/node_modules/express
/home/joan/escena-viva/src/node_modules/express
/home/joan/escena-viva/node_modules/express          <-- normalmente aqui
/home/joan/node_modules/express
/home/node_modules/express
/node_modules/express

Puedes ver esa lista en tiempo real:

console.log(module.paths);

Este mecanismo explica algo que confunde al principio: por qué a veces un paquete funciona sin estar en tu package.json. Estaba instalado en una carpeta superior y la búsqueda ascendente lo encontró. Es una dependencia accidental que dejará de funcionar en cuanto muevas el proyecto o alguien más lo instale desde cero. Lo veremos en el Módulo 5.

Cargar JSON directamente

CommonJS carga ficheros .json de forma nativa, ya analizados:

// Devuelve directamente el array, sin JSON.parse.
const catalogo = require('./datos/eventos.json');
console.log(catalogo.length);   // 3

Es cómodo y lo has usado en los node -p del Módulo 1. Pero tiene tres inconvenientes que hay que conocer:

  1. Es síncrono: bloquea el hilo principal mientras lee el fichero.
  2. Se cachea: si el fichero cambia en disco, require sigue devolviendo la versión antigua.
  3. No existe en módulos ES sin sintaxis adicional.

Para configuración de arranque es perfectamente aceptable. Para datos que cambian —como el catálogo de Escena Viva— usaremos fs en el Módulo 3.

  1. La caché de módulos: un módulo es un singleton

Esta es la característica de CommonJS con más consecuencias prácticas:

Un módulo se evalúa UNA SOLA VEZ. La primera vez que se le hace require, se ejecuta y su module.exports se guarda en caché. Todas las llamadas posteriores devuelven exactamente el mismo objeto.

Demostrémoslo con un contador:

// src/laboratorio/contador.js
console.log('>> El modulo contador.js se esta EVALUANDO');

let cuenta = 0;

function incrementar() {
  cuenta++;
  return cuenta;
}

function valor() {
  return cuenta;
}

module.exports = { incrementar, valor };
// src/laboratorio/usar-contador.js
console.log('--- Primer require ---');
const primero = require('./contador.js');

console.log('--- Segundo require ---');
const segundo = require('./contador.js');

console.log('--- Tercer require ---');
const tercero = require('./contador.js');

console.log('');
console.log('¿Son el mismo objeto?', primero === segundo, segundo === tercero);

primero.incrementar();
primero.incrementar();
segundo.incrementar();

console.log('Valor visto desde "primero":', primero.valor());
console.log('Valor visto desde "tercero":', tercero.valor());
--- Primer require ---
>> El modulo contador.js se esta EVALUANDO
--- Segundo require ---
--- Tercer require ---

¿Son el mismo objeto? true true
Valor visto desde "primero": 3
Valor visto desde "tercero": 3

Tres hechos en esa salida:

  1. El mensaje de evaluación aparece una sola vez, aunque hubo tres require.
  2. Los tres objetos son idénticos (===).
  3. El estado se comparte: incrementar desde una referencia lo ve todo el mundo.

En otras palabras: todo módulo CommonJS es un singleton dentro del proceso.

La utilidad

Es exactamente lo que quieres para recursos compartidos y caros de crear:

// src/datos/conexion.js
// El grupo de conexiones a la base de datos: uno solo en todo el proceso.

const grupo = crearGrupoDeConexiones({ maximo: 20 });

module.exports = { grupo };

Da igual desde cuántos ficheros se le haga require: siempre es el mismo grupo de 20 conexiones, no veinte grupos. Lo mismo sirve para el GestorDeVentas de Escena Viva, para un registrador (logger) o para una caché en memoria. Este patrón es el que usaremos en el Módulo 7.

Los riesgos

Riesgo 1: estado compartido no deseado.

// PELIGRO: la configuracion es mutable y global.
// src/config.js
module.exports = { limiteEntradasPorPedido: 10 };

// En otro fichero, alguien hace:
const config = require('./config.js');
config.limiteEntradasPorPedido = 999;   // Lo cambia para TODA la aplicacion

Remedio: congelar lo que debe ser inmutable.

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

Riesgo 2: las pruebas se contaminan entre sí.

Si un módulo acumula estado y varias pruebas lo usan, la segunda prueba hereda el estado de la primera. Es una fuente clásica de pruebas que pasan en solitario y fallan en conjunto. Lo trataremos en el Módulo 9; para módulos con estado, la solución habitual es exportar una función fábrica en lugar de una instancia:

// En lugar de exportar una instancia (singleton forzoso)...
module.exports = { gestor: new GestorDeVentas(catalogo) };

// ...exporta la clase y deja que cada quien cree la suya.
module.exports = { GestorDeVentas };

Riesgo 3: la clave de caché es la ruta resuelta. Dos rutas distintas que apunten al mismo fichero (por un enlace simbólico, o por mayúsculas y minúsculas en Windows) pueden producir dos instancias del mismo módulo. Es raro, pero cuando ocurre es desconcertante.

Inspeccionar y vaciar la caché

// Rutas de todos los modulos cargados
console.log(Object.keys(require.cache));

// Forzar que un modulo se vuelva a evaluar en el siguiente require
delete require.cache[require.resolve('./contador.js')];

const nuevo = require('./contador.js');   // Vuelve a evaluarse: cuenta = 0

Manipular require.cache es una herramienta de laboratorio, no de producción. Se usa en algunas configuraciones de pruebas y en recargadores en caliente de desarrollo. En código de aplicación es casi siempre señal de un problema de diseño.

  1. Dependencias circulares

Ocurre cuando A requiere a B y B requiere a A. Node no falla, pero el resultado sorprende.

// src/laboratorio/circular-a.js
console.log('A: empieza a evaluarse');

const b = require('./circular-b.js');
console.log('A: b vale', b);

module.exports = { nombre: 'modulo A' };
console.log('A: termina de evaluarse');
// src/laboratorio/circular-b.js
console.log('B: empieza a evaluarse');

const a = require('./circular-a.js');
console.log('B: a vale', a);          // <-- Aqui esta la sorpresa

module.exports = { nombre: 'modulo B' };
console.log('B: termina de evaluarse');
node src/laboratorio/circular-a.js
A: empieza a evaluarse
B: empieza a evaluarse
B: a vale {}          <-- Objeto VACIO, no { nombre: 'modulo A' }
B: termina de evaluarse
A: b vale { nombre: 'modulo B' }
A: termina de evaluarse

La explicación paso a paso:

Paso Qué ocurre
1 Se empieza a evaluar A. Node registra A en la caché con module.exports = {} (vacío)
2 A hace require('./circular-b.js'). Se empieza a evaluar B
3 B hace require('./circular-a.js'). A ya está en la caché, así que Node devuelve su module.exports actual: el objeto vacío
4 B termina y exporta lo suyo correctamente
5 A recibe el module.exports completo de B y termina

La regla: en una dependencia circular, el módulo que se carga en segundo lugar recibe una versión incompleta del primero. No es un fallo de Node: es la única cosa razonable que puede hacer sin entrar en un bucle infinito.

Y por eso las dependencias circulares producen errores tan desconcertantes:

// En circular-b.js
const { crearEvento } = require('./circular-a.js');
crearEvento();   // TypeError: crearEvento is not a function

Cómo evitarlas

Técnica Cómo
Extraer lo común a un tercer módulo Si A y B comparten algo, ese algo va a C, y ambos dependen de C
Invertir la dependencia En lugar de que A busque a B, que quien los usa a ambos les pase lo que necesitan
Mover el require dentro de la función Se resuelve en tiempo de ejecución, cuando ambos módulos ya están completos. Es un parche, no una solución
Usar eventos A emite y B escucha, sin que A conozca a B. Justo lo de la lección anterior

Esa última fila no es casual. Muchas dependencias circulares son un síntoma de acoplamiento excesivo, y el patrón observador de la lección Eventos y EventEmitter es a menudo la solución de diseño correcta, no solo un truco para romper el ciclo.

Para detectarlas en un proyecto real existen herramientas como madge, que dibuja el grafo de dependencias y señala los ciclos.

  1. Refactorización de Escena Viva

Ha llegado el momento. Vamos a dejar el proyecto como debe estar al terminar el Módulo 2.

8.1 El punto de partida y el destino

ANTES                                DESPUES
escena-viva/                         escena-viva/
├── datos/                           ├── datos/
│   └── eventos.json                 │   └── eventos.json
└── src/                             └── src/
    ├── catalogo.js   <- datos           ├── catalogo.js       <- solo presentacion
    │                    incrustados     ├── catalogo-datos.js <- solo datos
    └── catalogo-datos.js  <- sin        ├── dominio/
                        module.exports   │   ├── sesion.js
                                         │   ├── evento.js
                                         │   ├── gestor-ventas.js
                                         │   └── index.js
                                         └── utiles/
                                             └── formato.js
flowchart TD
    C["src/catalogo.js<br/><i>punto de entrada</i>"]
    CD["src/catalogo-datos.js<br/><i>los datos</i>"]
    DI["src/dominio/index.js<br/><i>fachada</i>"]
    EV["src/dominio/evento.js"]
    SE["src/dominio/sesion.js"]
    GV["src/dominio/gestor-ventas.js"]
    FO["src/utiles/formato.js"]

    C --> CD
    C --> DI
    C --> FO
    DI --> EV
    DI --> SE
    DI --> GV
    EV --> SE
    GV --> SE

Fíjate en la forma del grafo: todas las flechas van en una dirección. No hay ciclos, y las dependencias van de lo general (catalogo.js) a lo específico (sesion.js). Ese es el objetivo de todo diseño de módulos.

8.2 src/utiles/formato.js

Empezamos por abajo: el módulo que no depende de nada.

// src/utiles/formato.js
// Utilidades de presentacion de Escena Viva.
// Sin dependencias: es el modulo mas basico del proyecto.

// Convierte 2500 (centimos) en la cadena '25,00 EUR'.
function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  const resto = String(centimos % 100).padStart(2, '0');
  return `${euros},${resto} EUR`;
}

// Convierte '2026-10-03T20:00:00' en '03/10/2026 20:00'.
function formatearFecha(fechaISO) {
  const fecha = new Date(fechaISO);
  const dia = String(fecha.getDate()).padStart(2, '0');
  const mes = String(fecha.getMonth() + 1).padStart(2, '0');
  const anio = fecha.getFullYear();
  const hora = String(fecha.getHours()).padStart(2, '0');
  const minuto = String(fecha.getMinutes()).padStart(2, '0');
  return `${dia}/${mes}/${anio} ${hora}:${minuto}`;
}

// Genera un codigo de entrada: EV-2026-000123
function generarCodigoEntrada(anio, secuencia) {
  return `EV-${anio}-${String(secuencia).padStart(6, '0')}`;
}

module.exports = { formatearPrecio, formatearFecha, generarCodigoEntrada };

Esas tres funciones estaban duplicadas en varios ficheros del Módulo 1. Ahora existen una sola vez.

8.3 src/dominio/sesion.js

La clase Sesion que escribiste como solución de ejercicio en la lección JavaScript Moderno para Node.js, ahora con casa propia.

// src/dominio/sesion.js
// Una sesion es un pase concreto de un evento, con su fecha, aforo y precio.

const { formatearPrecio, formatearFecha } = require('../utiles/formato.js');

class Sesion {
  // Campo privado: la unica forma de modificarlo es a traves de vender().
  #vendidas = 0;

  constructor({ id, fechaHora, aforo, vendidas = 0, precioCentimos }) {
    this.id = id;
    this.fechaHora = fechaHora;
    this.aforo = aforo;
    this.precioCentimos = precioCentimos;
    this.#vendidas = vendidas;
  }

  get vendidas() {
    return this.#vendidas;
  }

  get libres() {
    return this.aforo - this.#vendidas;
  }

  get ocupacion() {
    return Math.round((this.#vendidas / this.aforo) * 100);
  }

  get agotada() {
    return this.libres === 0;
  }

  get precioEuros() {
    return (this.precioCentimos / 100).toFixed(2);
  }

  // Recaudacion en centimos, entero.
  get recaudacionCentimos() {
    return this.#vendidas * this.precioCentimos;
  }

  vender(cantidad = 1) {
    if (!Number.isInteger(cantidad) || cantidad < 1) {
      const error = new Error('La cantidad debe ser un entero positivo');
      error.codigo = 'CANTIDAD_INVALIDA';
      throw error;
    }
    if (cantidad > this.libres) {
      const error = new Error(`Aforo insuficiente en ${this.id}: quedan ${this.libres}`);
      error.codigo = 'AFORO_INSUFICIENTE';
      throw error;
    }

    this.#vendidas += cantidad;
    return this.#vendidas;
  }

  // Linea de una sola fila para el listado por consola.
  describir() {
    return (
      `${formatearFecha(this.fechaHora)}  ${formatearPrecio(this.precioCentimos)}  ` +
      `${this.libres}/${this.aforo} libres  (${this.ocupacion}% ocupado)` +
      (this.agotada ? '  [AGOTADA]' : '')
    );
  }

  // JSON.stringify llama automaticamente a toJSON si existe.
  // Sin esto, el campo privado #vendidas no apareceria en la serializacion.
  toJSON() {
    return {
      id: this.id,
      fechaHora: this.fechaHora,
      aforo: this.aforo,
      vendidas: this.#vendidas,
      precioCentimos: this.precioCentimos
    };
  }
}

module.exports = { Sesion };

8.4 src/dominio/evento.js

// src/dominio/evento.js
// Un evento es un espectaculo programado, con una o varias sesiones.

const { Sesion } = require('./sesion.js');

class Evento {
  #sesiones = [];

  constructor({ id, titulo, sala, organizador, categoria, duracionMinutos, estado = 'publicado', sesiones = [] }) {
    this.id = id;
    this.titulo = titulo;
    this.sala = sala;
    this.organizador = organizador;
    this.categoria = categoria;
    this.duracionMinutos = duracionMinutos;
    this.estado = estado;

    // Convertimos los objetos planos en instancias de Sesion.
    // Si ya lo son, los dejamos como estan.
    this.#sesiones = sesiones.map((s) => (s instanceof Sesion ? s : new Sesion(s)));
  }

  get sesiones() {
    // Copia defensiva: nadie de fuera puede anadir ni quitar sesiones.
    return [...this.#sesiones];
  }

  get numeroSesiones() {
    return this.#sesiones.length;
  }

  get aforoTotal() {
    return this.#sesiones.reduce((total, s) => total + s.aforo, 0);
  }

  get entradasVendidas() {
    return this.#sesiones.reduce((total, s) => total + s.vendidas, 0);
  }

  get entradasLibresTotales() {
    return this.aforoTotal - this.entradasVendidas;
  }

  get ocupacion() {
    if (this.aforoTotal === 0) return 0;
    return Math.round((this.entradasVendidas / this.aforoTotal) * 100);
  }

  get recaudacionCentimos() {
    return this.#sesiones.reduce((total, s) => total + s.recaudacionCentimos, 0);
  }

  get agotado() {
    return this.#sesiones.every((s) => s.agotada);
  }

  buscarSesion(idSesion) {
    return this.#sesiones.find((s) => s.id === idSesion);
  }

  entradasLibres(idSesion) {
    const sesion = this.buscarSesion(idSesion);
    return sesion ? sesion.libres : 0;
  }

  reservar(idSesion, cantidad = 1) {
    const sesion = this.buscarSesion(idSesion);

    if (!sesion) {
      const error = new Error(`La sesion ${idSesion} no existe en ${this.id}`);
      error.codigo = 'SESION_NO_ENCONTRADA';
      throw error;
    }

    // Delegamos en Sesion: es ella quien sabe validar su propio aforo.
    return sesion.vender(cantidad);
  }

  toString() {
    return `${this.titulo} (${this.sala}) - ${this.ocupacion}% ocupado`;
  }

  toJSON() {
    return {
      id: this.id,
      titulo: this.titulo,
      sala: this.sala,
      organizador: this.organizador,
      categoria: this.categoria,
      duracionMinutos: this.duracionMinutos,
      estado: this.estado,
      sesiones: this.#sesiones.map((s) => s.toJSON())
    };
  }

  static desdeJSON(objeto) {
    return new Evento(objeto);
  }
}

module.exports = { Evento };

Fíjate en la mejora de diseño respecto a la versión del Módulo 1: Evento.reservar ya no valida el aforo a mano, sino que delega en sesion.vender(). Cada clase sabe validar lo suyo. Esto solo es posible ahora que Sesion existe como módulo independiente y Evento puede requerirla.

8.5 src/dominio/index.js: la fachada

// src/dominio/index.js
// Fachada del dominio: un unico punto de entrada para todo el modelo.
// Permite escribir require('./dominio') en lugar de tres require distintos.

const { Sesion } = require('./sesion.js');
const { Evento } = require('./evento.js');
const { GestorDeVentas, UMBRAL_AFORO_BAJO } = require('./gestor-ventas.js');

module.exports = { Sesion, Evento, GestorDeVentas, UMBRAL_AFORO_BAJO };

Este patrón —un index.js que reexporta— se llama fachada o barrel, y aporta dos cosas:

// Sin fachada: tres lineas, y quien importa debe conocer la estructura interna.
const { Sesion } = require('./dominio/sesion.js');
const { Evento } = require('./dominio/evento.js');
const { GestorDeVentas } = require('./dominio/gestor-ventas.js');

// Con fachada: una linea, y la estructura interna puede cambiar sin romper nada.
const { Sesion, Evento, GestorDeVentas } = require('./dominio');

La segunda ventaja es la importante: si mañana divides evento.js en dos ficheros, solo cambias el index.js. Nadie más se entera.

8.6 src/catalogo-datos.js: por fin, un módulo

Aquí se salda la deuda concreta del Módulo 1.

// src/catalogo-datos.js
// Fuente de datos del catalogo de Escena Viva.
// Hasta el modulo 3 los datos estan aqui incrustados; despues se leeran
// de datos/eventos.json con fs, y este fichero sera el unico que cambie.

const catalogo = [
  {
    id: 'evt-001',
    titulo: 'Concierto de Otono',
    sala: 'Teatro Almendra',
    organizador: 'org-almendra',
    categoria: 'concierto',
    duracionMinutos: 95,
    estado: 'publicado',
    sesiones: [
      { id: 'ses-001-1', fechaHora: '2026-10-03T20:00:00', aforo: 420, vendidas: 180, precioCentimos: 2500 },
      { id: 'ses-001-2', fechaHora: '2026-10-04T19:00:00', aforo: 420, vendidas: 96,  precioCentimos: 2200 }
    ]
  },
  {
    id: 'evt-002',
    titulo: 'Noche de Monologos',
    sala: 'Sala Boveda',
    organizador: 'org-boveda',
    categoria: 'humor',
    duracionMinutos: 80,
    estado: 'publicado',
    sesiones: [
      { id: 'ses-002-1', fechaHora: '2026-10-10T21:30:00', aforo: 120, vendidas: 118, precioCentimos: 1800 },
      { id: 'ses-002-2', fechaHora: '2026-10-11T21:30:00', aforo: 120, vendidas: 45,  precioCentimos: 1800 },
      { id: 'ses-002-3', fechaHora: '2026-10-17T21:30:00', aforo: 120, vendidas: 12,  precioCentimos: 1500 }
    ]
  },
  {
    id: 'evt-003',
    titulo: 'Festival de Jazz de Primavera',
    sala: 'Auditorio Ribera',
    organizador: 'org-ribera',
    categoria: 'festival',
    duracionMinutos: 240,
    estado: 'publicado',
    sesiones: [
      { id: 'ses-003-1', fechaHora: '2027-04-17T19:00:00', aforo: 900, vendidas: 640, precioCentimos: 3800 },
      { id: 'ses-003-2', fechaHora: '2027-04-18T19:00:00', aforo: 900, vendidas: 720, precioCentimos: 4200 }
    ]
  }
];

// Devuelve una copia profunda para que nadie modifique la fuente por accidente.
// structuredClone es nativo en Node desde la version 17.
function obtenerCatalogo() {
  return structuredClone(catalogo);
}

function obtenerEventoPorId(id) {
  const evento = catalogo.find((e) => e.id === id);
  return evento ? structuredClone(evento) : undefined;
}

module.exports = { obtenerCatalogo, obtenerEventoPorId };

Dos decisiones importantes:

  1. Exportamos funciones, no el array directamente. Si exportáramos module.exports = { catalogo }, cualquiera podría modificar los datos de origen y, por la caché de módulos, esa modificación afectaría a todo el proceso. Devolver una copia con structuredClone protege la fuente.
  2. La firma será la misma cuando lleguen los datos reales. En el Módulo 3, obtenerCatalogo() leerá datos/eventos.json con fs y pasará a ser asíncrona. Ningún consumidor tendrá que cambiar su estructura, solo añadir un await. Esa es la razón de existir de esta capa.

8.7 src/catalogo.js: el punto de entrada

// src/catalogo.js
// Punto de entrada del catalogo por consola de Escena Viva.
// Uso:
//   node src/catalogo.js
//   node src/catalogo.js --sala="Teatro Almendra"
//   node src/catalogo.js --tabla
//   node src/catalogo.js --max=2000

const { obtenerCatalogo } = require('./catalogo-datos.js');
const { Evento } = require('./dominio');
const { formatearPrecio } = require('./utiles/formato.js');

// --- Lectura de argumentos ---

function leerOpciones(argumentos) {
  const opciones = { sala: null, tabla: false, precioMaximoCentimos: Infinity };

  for (const argumento of argumentos) {
    if (argumento === '--tabla') {
      opciones.tabla = true;
    } else if (argumento.startsWith('--sala=')) {
      opciones.sala = argumento.slice('--sala='.length);
    } else if (argumento.startsWith('--max=')) {
      opciones.precioMaximoCentimos = Number(argumento.slice('--max='.length));
    }
  }

  return opciones;
}

// --- Presentacion ---

function mostrarDetalle(eventos) {
  console.log('');
  console.log('==========================================');
  console.log('   ESCENA VIVA - CATALOGO DE EVENTOS');
  console.log('==========================================');

  for (const evento of eventos) {
    console.log('');
    console.log(`${evento.titulo}  [${evento.id}]`);
    console.log(`  Sala      : ${evento.sala}`);
    console.log(`  Categoria : ${evento.categoria}`);
    console.log(`  Duracion  : ${evento.duracionMinutos} min`);
    console.log(`  Sesiones  : ${evento.numeroSesiones}`);
    console.log(`  Libres    : ${evento.entradasLibresTotales} entradas`);
    console.log(`  Ocupacion : ${evento.ocupacion}%`);

    for (const sesion of evento.sesiones) {
      // La sesion sabe describirse a si misma: catalogo.js no calcula nada.
      console.log(`    - ${sesion.describir()}`);
    }
  }

  console.log('');
}

function mostrarTabla(eventos) {
  const filas = eventos.flatMap((evento) =>
    evento.sesiones.map((sesion) => ({
      evento: evento.id,
      titulo: evento.titulo,
      sala: evento.sala,
      sesion: sesion.id,
      precio: formatearPrecio(sesion.precioCentimos),
      libres: sesion.libres,
      ocupacion: `${sesion.ocupacion}%`
    }))
  );

  console.table(filas);
}

// --- Programa principal ---

function principal() {
  const opciones = leerOpciones(process.argv.slice(2));

  // Los datos planos se convierten en objetos de dominio.
  let eventos = obtenerCatalogo().map((datos) => Evento.desdeJSON(datos));

  if (opciones.sala) {
    eventos = eventos.filter((evento) => evento.sala === opciones.sala);
  }

  if (opciones.precioMaximoCentimos < Infinity) {
    eventos = eventos.filter((evento) =>
      evento.sesiones.some((s) => s.precioCentimos <= opciones.precioMaximoCentimos)
    );
  }

  if (eventos.length === 0) {
    // Diagnostico por stderr, segun la convencion del proyecto.
    console.error('No hay eventos que cumplan los criterios indicados.');
    process.exitCode = 1;
    return;
  }

  if (opciones.tabla) {
    mostrarTabla(eventos);
  } else {
    mostrarDetalle(eventos);
  }

  const aforoTotal = eventos.reduce((t, e) => t + e.aforoTotal, 0);
  const vendidas = eventos.reduce((t, e) => t + e.entradasVendidas, 0);
  const recaudacion = eventos.reduce((t, e) => t + e.recaudacionCentimos, 0);

  console.error(
    `${eventos.length} eventos | aforo ${aforoTotal} | vendidas ${vendidas} | ` +
    `libres ${aforoTotal - vendidas} | recaudacion ${formatearPrecio(recaudacion)}`
  );
}

// Solo se ejecuta si este fichero es el programa principal.
if (require.main === module) {
  principal();
}

module.exports = { leerOpciones, principal };

Comprobación:

node src/catalogo.js --tabla
┌─────────┬───────────┬────────────────────────────────┬────────────────────┬─────────────┬─────────────┬────────┬───────────┐
│ (index) │ evento    │ titulo                         │ sala               │ sesion      │ precio      │ libres │ ocupacion │
├─────────┼───────────┼────────────────────────────────┼────────────────────┼─────────────┼─────────────┼────────┼───────────┤
│ 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 eventos | aforo 3000 | vendidas 1811 | libres 1189 | recaudacion 62298,00 EUR

Los totales coinciden con la semilla del Módulo 1: 3000 de aforo, 1811 vendidas, 1189 libres. La refactorización no ha cambiado ni un dato.

Y lo importante: mira lo que ya no hay en catalogo.js. No hay array de datos, no hay formatearPrecio duplicado, no hay cálculos de ocupación. Solo hay lectura de argumentos y presentación. Cada módulo hace una cosa.

  1. Buenas prácticas de diseño de módulos

Las reglas que seguiremos en todo el curso.

9.1 Una responsabilidad por módulo

Si al describir un módulo tienes que usar "y", probablemente son dos:

Mal Bien
utiles.js con formateo, validación, fechas y cálculos formato.js, validacion.js, fechas.js
evento.js que además lee el fichero de datos evento.js (modelo) + catalogo-datos.js (acceso)

Un utiles.js que crece sin límite es el destino de todo lo que nadie sabe dónde poner. Cuando pase, divídelo.

9.2 Exporta poco

La API pública de un módulo es un compromiso. Todo lo que exportas es algo que alguien puede usar y que, por tanto, no podrás cambiar sin romper código ajeno.

// MAL: expone detalles internos que nadie de fuera necesita.
module.exports = {
  formatearPrecio,
  partirCentimos,        // Auxiliar interno
  SIMBOLO_MONEDA,        // Constante interna
  cacheDeFormatos        // Estado interno
};

// BIEN: solo lo que forma parte del contrato.
module.exports = { formatearPrecio };

Empieza exportando el mínimo. Ampliar una API es fácil; reducirla, no.

9.3 Sin efectos secundarios en la carga

Un require debería ser barato y seguro: definir cosas, no hacerlas.

// MAL: al requerir este modulo se abre una conexion, se lee un fichero
// y se imprime por consola. Sin que nadie lo haya pedido.
const conexion = conectarABaseDeDatos();
const datos = fs.readFileSync('datos/eventos.json');
console.log('Modulo de catalogo cargado');

module.exports = { conexion, datos };
// BIEN: el modulo define capacidades. Quien las quiera, las invoca.
function conectar(opciones) { /* ... */ }
function cargarDatos(ruta) { /* ... */ }

module.exports = { conectar, cargarDatos };

Un módulo con efectos secundarios es imposible de probar en aislamiento, hace que el arranque sea lento e impredecible, y —por la caché— sus efectos ocurren una sola vez en un momento que no controlas.

La excepción legítima es el patrón require.main === module del apartado 4: efectos solo cuando el fichero es el programa.

9.4 Sitúa los require al principio

// Al principio del fichero, agrupados y ordenados:
const path = require('node:path');           // 1. Nucleo de Node
const express = require('express');          // 2. Paquetes externos
const { Evento } = require('./dominio');     // 3. Modulos propios

Así, cualquiera que abra el fichero ve sus dependencias en tres segundos. Un require escondido en mitad de una función es una dependencia que nadie va a encontrar.

9.5 Evita los require condicionales

// MAL: la dependencia solo se descubre en tiempo de ejecucion.
if (process.env.MODO === 'produccion') {
  registrador = require('./registrador-produccion.js');
}

Carga ambos y elige, o usa una fábrica. La única excepción razonable es la carga perezosa de un módulo muy pesado que rara vez se usa, y aun así conviene documentarlo.

9.6 Prefiere fábricas a instancias cuando haya estado

// Menos flexible: fuerza un singleton y complica las pruebas.
module.exports = { gestor: new GestorDeVentas(catalogo) };

// Mas flexible: cada quien crea el suyo cuando y como quiera.
module.exports = { GestorDeVentas };

Exporta la instancia solo cuando el singleton es deliberado y deseado: un grupo de conexiones, un registrador global, una caché compartida.

Errores Comunes y Consejos

Error 1: exports = { ... } en lugar de module.exports = { ... }. El módulo exporta un objeto vacío y quien lo importa recibe undefined en todo. Usa siempre module.exports.

Error 2: olvidar la extensión en rutas relativas. Funciona en CommonJS, pero es ambiguo y no funcionará en módulos ES. Escribe ./sesion.js.

Error 3: usar ./ para un paquete de npm o al revés. require('express') busca en node_modules; require('./express') busca un fichero. Son cosas distintas.

Error 4: creer que cada require crea una instancia nueva. Es un singleton cacheado. Si necesitas instancias independientes, exporta la clase o una fábrica.

Error 5: mutar un objeto exportado por otro módulo. Como todos comparten el mismo objeto, el cambio afecta a toda la aplicación. Object.freeze para la configuración, y copias defensivas para los datos.

Error 6: dependencias circulares. Uno de los dos módulos recibirá un objeto incompleto y el error será incomprensible. Extrae lo común a un tercer módulo o usa eventos.

Error 7: require dentro de un bucle o de una función caliente. La caché lo hace barato tras la primera vez, pero la búsqueda en la caché no es gratis. Al principio del fichero.

Error 8: un utiles.js que lo tiene todo. Acaba siendo un módulo del que depende todo el proyecto y que nadie se atreve a tocar.

Consejo 1: un module.exports único al final del fichero. Ese es el índice de la API pública de tu módulo.

Consejo 2: usa node: para los módulos del núcleo. require('node:fs'), require('node:path'), require('node:events').

Consejo 3: usa __dirname para construir rutas. Nunca dependas de process.cwd().

Consejo 4: dibuja el grafo de dependencias de tu proyecto. Si tiene ciclos o si un módulo tiene quince flechas entrantes, ya sabes dónde está el problema de diseño.

Ejercicios

Ejercicio 1: diagnosticar un módulo roto

Este módulo tiene cinco problemas según lo aprendido. Encuéntralos, explica la consecuencia de cada uno y reescríbelo.

// src/utiles/ayudas.js
const fs = require('fs');

console.log('Cargando ayudas...');

const catalogo = JSON.parse(fs.readFileSync('./datos/eventos.json', 'utf8'));

var CONFIG = { limite: 10 };

function formatearPrecio(c) {
  return (c / 100).toFixed(2) + ' EUR';
}

function _redondear(n) {
  return Math.round(n);
}

exports = { formatearPrecio, _redondear, CONFIG, catalogo };

Ejercicio 2: el módulo de informes

Crea src/informes/ocupacion.js, un módulo que:

  1. Dependa solo de ../dominio y ../utiles/formato.js (no de catalogo-datos.js: los datos se le pasan).
  2. Exporte tres funciones:
    • resumirPorSala(eventos) → array de { sala, eventos, sesiones, aforo, vendidas, libres, ocupacion, recaudacionEuros }.
    • sesionesEnRiesgo(eventos, umbralPorcentaje) → sesiones por debajo del umbral de ocupación.
    • sesionesAgotadas(eventos) → sesiones sin entradas libres.
  3. Sea ejecutable directamente con node src/informes/ocupacion.js gracias a require.main === module, cargando el catálogo y mostrando los tres informes con console.table.
  4. Al ser importado con require, no imprima absolutamente nada.

Verifica ambos comportamientos: la ejecución directa y un fichero que lo importe y solo llame a sesionesAgotadas.

Ejercicio 3: caché de módulos y dependencia circular

Escribe dos programas de laboratorio que demuestren experimentalmente lo aprendido:

Parte A — src/laboratorio/demostrar-cache.js:

  1. Un módulo src/laboratorio/registro-ventas.js que mantenga un array privado de ventas, exporte anotar(venta), total() y listar(), e imprima un mensaje al ser evaluado.
  2. Un programa que lo requiera desde tres puntos distintos (el propio programa y dos módulos auxiliares), anote ventas desde cada uno y demuestre que el total es compartido.
  3. Que después vacíe la caché con delete require.cache[require.resolve(...)], lo vuelva a requerir y demuestre que el estado se ha perdido.
  4. Que imprima cuántos módulos hay en require.cache antes y después.

Parte B — src/laboratorio/circular-*.js:

Crea una dependencia circular real entre un módulo pedido.js y un módulo entrada.js (un pedido tiene entradas; una entrada conoce su pedido), demuestra el fallo con una traza de mensajes, y después arréglala con la técnica que consideres correcta, justificando la elección.

Soluciones

Solución 1

Los cinco problemas:

# Problema Consecuencia
1 exports = { ... } en lugar de module.exports El módulo no exporta nada. Quien lo importe recibe {}
2 Efecto secundario en la carga: lee un fichero y hace console.log Requerir el módulo bloquea el hilo y ensucia la salida sin que nadie lo pida
3 Ruta relativa a process.cwd() ('./datos/eventos.json') Falla si se ejecuta desde otra carpeta. Debe usar __dirname
4 Exporta detalles internos (_redondear, CONFIG, catalogo) Amplía el contrato público con cosas que deberían ser privadas, y CONFIG es mutable globalmente
5 var y require('fs') sin prefijo node: var no tiene ámbito de bloque; el prefijo evita ambigüedad con paquetes

Y un sexto de propina: el módulo mezcla responsabilidades (formateo, configuración y acceso a datos). Deberían ser tres módulos.

Versión corregida, dividida como corresponde:

// src/utiles/formato.js
// Solo formateo. Sin dependencias, sin efectos secundarios.

function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  const resto = String(centimos % 100).padStart(2, '0');
  return `${euros},${resto} EUR`;
}

module.exports = { formatearPrecio };
// src/config.js
// Configuracion de la aplicacion. Congelada para que nadie la modifique.

module.exports = Object.freeze({
  limiteEntradasPorPedido: 10,
  umbralAforoBajo: 0.10
});
// src/catalogo-datos.js
// Acceso a los datos. La lectura ocurre cuando se PIDE, no al cargar.

const fs = require('node:fs');
const path = require('node:path');

// Ruta relativa AL FICHERO, no al directorio de trabajo.
const RUTA_DATOS = path.join(__dirname, '..', 'datos', 'eventos.json');

function obtenerCatalogo() {
  const contenido = fs.readFileSync(RUTA_DATOS, 'utf8');
  return JSON.parse(contenido);
}

module.exports = { obtenerCatalogo };

(La versión asíncrona de esa lectura llega en el Módulo 3; aquí lo que importa es que la lectura está dentro de una función.)

Solución 2

// src/informes/ocupacion.js
// Informes de ocupacion de Escena Viva.
// Recibe los eventos como argumento: no conoce su origen.

const { formatearPrecio } = require('../utiles/formato.js');

// Resumen agregado por sala.
function resumirPorSala(eventos) {
  const porSala = new Map();

  for (const evento of eventos) {
    const acumulado = porSala.get(evento.sala) ?? {
      sala: evento.sala, eventos: 0, sesiones: 0,
      aforo: 0, vendidas: 0, recaudacionCentimos: 0
    };

    acumulado.eventos += 1;
    acumulado.sesiones += evento.numeroSesiones;
    acumulado.aforo += evento.aforoTotal;
    acumulado.vendidas += evento.entradasVendidas;
    acumulado.recaudacionCentimos += evento.recaudacionCentimos;

    porSala.set(evento.sala, acumulado);
  }

  return [...porSala.values()].map((a) => ({
    sala: a.sala,
    eventos: a.eventos,
    sesiones: a.sesiones,
    aforo: a.aforo,
    vendidas: a.vendidas,
    libres: a.aforo - a.vendidas,
    ocupacion: `${Math.round((a.vendidas / a.aforo) * 100)}%`,
    recaudacionEuros: formatearPrecio(a.recaudacionCentimos)
  }));
}

// Sesiones por debajo del umbral de ocupacion.
function sesionesEnRiesgo(eventos, umbralPorcentaje = 20) {
  return eventos.flatMap((evento) =>
    evento.sesiones
      .filter((sesion) => sesion.ocupacion < umbralPorcentaje)
      .map((sesion) => ({
        evento: evento.id,
        titulo: evento.titulo,
        sesion: sesion.id,
        libres: sesion.libres,
        ocupacion: `${sesion.ocupacion}%`
      }))
  );
}

// Sesiones sin entradas disponibles.
function sesionesAgotadas(eventos) {
  return eventos.flatMap((evento) =>
    evento.sesiones
      .filter((sesion) => sesion.agotada)
      .map((sesion) => ({
        evento: evento.id,
        titulo: evento.titulo,
        sesion: sesion.id,
        aforo: sesion.aforo,
        recaudacionEuros: formatearPrecio(sesion.recaudacionCentimos)
      }))
  );
}

// --- Ejecucion directa: solo si este fichero ES el programa principal ---
if (require.main === module) {
  const { obtenerCatalogo } = require('../catalogo-datos.js');
  const { Evento } = require('../dominio');

  const eventos = obtenerCatalogo().map((datos) => Evento.desdeJSON(datos));
  const umbral = Number(process.argv[2]) || 20;

  console.log('OCUPACION POR SALA');
  console.table(resumirPorSala(eventos));

  console.log('');
  console.log(`SESIONES EN RIESGO (menos del ${umbral}%)`);
  const riesgo = sesionesEnRiesgo(eventos, umbral);
  if (riesgo.length === 0) {
    console.log('  Ninguna.');
  } else {
    console.table(riesgo);
    process.exitCode = 1;
  }

  console.log('');
  console.log('SESIONES AGOTADAS');
  const agotadas = sesionesAgotadas(eventos);
  if (agotadas.length === 0) {
    console.log('  Ninguna.');
  } else {
    console.table(agotadas);
  }
}

module.exports = { resumirPorSala, sesionesEnRiesgo, sesionesAgotadas };
node src/informes/ocupacion.js
OCUPACION POR SALA
┌─────────┬────────────────────┬─────────┬──────────┬───────┬──────────┬────────┬───────────┬──────────────────┐
│ (index) │ sala               │ eventos │ sesiones │ aforo │ vendidas │ libres │ ocupacion │ recaudacionEuros │
├─────────┼────────────────────┼─────────┼──────────┼───────┼──────────┼────────┼───────────┼──────────────────┤
│ 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'   │
└─────────┴────────────────────┴─────────┴──────────┴───────┴──────────┴────────┴───────────┴──────────────────┘

SESIONES EN RIESGO (menos del 20%)
┌─────────┬───────────┬──────────────────────┬─────────────┬────────┬───────────┐
│ (index) │ evento    │ titulo               │ sesion      │ libres │ ocupacion │
├─────────┼───────────┼──────────────────────┼─────────────┼────────┼───────────┤
│ 0       │ 'evt-002' │ 'Noche de Monologos' │ 'ses-002-3' │ 108    │ '10%'     │
└─────────┴───────────┴──────────────────────┴─────────────┴────────┴───────────┘

SESIONES AGOTADAS
  Ninguna.

Y la comprobación de que importarlo no imprime nada:

// src/laboratorio/probar-informes.js
const { sesionesAgotadas } = require('../informes/ocupacion.js');
const { obtenerCatalogo } = require('../catalogo-datos.js');
const { Evento } = require('../dominio');

const eventos = obtenerCatalogo().map((d) => Evento.desdeJSON(d));
console.log(`Agotadas: ${sesionesAgotadas(eventos).length}`);
node src/laboratorio/probar-informes.js
# Agotadas: 0

Solo esa línea. Ni las tablas ni los encabezados aparecen, porque require.main !== module. Esa es la diferencia entre un módulo bien diseñado y uno con efectos secundarios.

Solución 3, parte A

// src/laboratorio/registro-ventas.js
console.error('>> registro-ventas.js EVALUANDO (esto solo debe verse una vez)');

const ventas = [];

function anotar(venta) {
  ventas.push({ ...venta, anotadaEn: new Date().toISOString() });
  return ventas.length;
}

function total() {
  return ventas.reduce((t, v) => t + v.importeCentimos, 0);
}

function listar() {
  return [...ventas];
}

module.exports = { anotar, total, listar };
// src/laboratorio/taquilla-a.js
const registro = require('./registro-ventas.js');

function venderDesdeTaquillaA() {
  registro.anotar({ sesionId: 'ses-001-1', cantidad: 2, importeCentimos: 5000 });
}

module.exports = { venderDesdeTaquillaA };
// src/laboratorio/taquilla-b.js
const registro = require('./registro-ventas.js');

function venderDesdeTaquillaB() {
  registro.anotar({ sesionId: 'ses-002-2', cantidad: 3, importeCentimos: 5400 });
}

module.exports = { venderDesdeTaquillaB };
// src/laboratorio/demostrar-cache.js
console.error(`Modulos en cache al empezar: ${Object.keys(require.cache).length}`);

const registro = require('./registro-ventas.js');
const { venderDesdeTaquillaA } = require('./taquilla-a.js');
const { venderDesdeTaquillaB } = require('./taquilla-b.js');

console.error(`Modulos en cache tras los require: ${Object.keys(require.cache).length}`);
console.error('');

// Ventas desde tres puntos distintos del programa.
registro.anotar({ sesionId: 'ses-003-1', cantidad: 1, importeCentimos: 3800 });
venderDesdeTaquillaA();
venderDesdeTaquillaB();

console.log('--- Estado compartido ---');
console.log(`Ventas anotadas: ${registro.listar().length}`);
console.log(`Total: ${(registro.total() / 100).toFixed(2)} EUR`);

// Comprobacion de identidad.
const otraReferencia = require('./registro-ventas.js');
console.log(`¿Mismo objeto? ${registro === otraReferencia}`);
console.log(`Total visto desde la otra referencia: ${(otraReferencia.total() / 100).toFixed(2)} EUR`);

// --- Vaciado de la cache ---
console.log('');
console.log('--- Tras vaciar la cache ---');

const ruta = require.resolve('./registro-ventas.js');
delete require.cache[ruta];

const registroNuevo = require('./registro-ventas.js');   // Vuelve a evaluarse
console.log(`¿Mismo objeto que antes? ${registro === registroNuevo}`);
console.log(`Ventas en la instancia nueva: ${registroNuevo.listar().length}`);
console.log(`Ventas en la instancia antigua: ${registro.listar().length}`);

console.error('');
console.error(`Modulos en cache al terminar: ${Object.keys(require.cache).length}`);
Modulos en cache al empezar: 1
>> registro-ventas.js EVALUANDO (esto solo debe verse una vez)
Modulos en cache tras los require: 4

--- Estado compartido ---
Ventas anotadas: 3
Total: 142.00 EUR
¿Mismo objeto? true
Total visto desde la otra referencia: 142.00 EUR

--- Tras vaciar la cache ---
>> registro-ventas.js EVALUANDO (esto solo debe verse una vez)
¿Mismo objeto que antes? false
Ventas en la instancia nueva: 0
Ventas en la instancia antigua: 3

Modulos en cache al terminar: 4

Tres conclusiones que se leen directamente en la salida:

  • El mensaje de evaluación aparece una sola vez pese a los cuatro require, y las tres ventas anotadas desde puntos distintos suman en el mismo array. Es el singleton.
  • Tras vaciar la caché, el módulo se vuelve a evaluar y la instancia nueva empieza de cero, mientras la antigua conserva su estado. Coexisten dos copias del mismo módulo, cada una con sus datos.
  • Esa última frase es exactamente la razón por la que manipular require.cache en producción es peligroso: puedes acabar con dos "registros de ventas" y ventas repartidas entre ambos sin que nadie se dé cuenta.

Solución 3, parte B

La dependencia circular:

// src/laboratorio/circular-pedido.js
console.error('pedido.js: empieza');

const { Entrada } = require('./circular-entrada.js');
console.error('pedido.js: Entrada vale', typeof Entrada);

class Pedido {
  constructor(id, sesionId, cantidad) {
    this.id = id;
    this.entradas = [];
    for (let i = 0; i < cantidad; i++) {
      this.entradas.push(new Entrada(`EV-2026-00000${i + 1}`, sesionId, this));
    }
  }
}

module.exports = { Pedido };
console.error('pedido.js: termina');
// src/laboratorio/circular-entrada.js
console.error('entrada.js: empieza');

const { Pedido } = require('./circular-pedido.js');
console.error('entrada.js: Pedido vale', typeof Pedido);   // <-- undefined

class Entrada {
  constructor(codigo, sesionId, pedido) {
    this.codigo = codigo;
    this.sesionId = sesionId;
    this.pedido = pedido;
    this.estado = 'valida';
  }

  // Usa Pedido para validar: fallara si Pedido es undefined.
  perteneceAPedidoValido() {
    return this.pedido instanceof Pedido;
  }
}

module.exports = { Entrada };
console.error('entrada.js: termina');
node src/laboratorio/circular-pedido.js
pedido.js: empieza
entrada.js: empieza
entrada.js: Pedido vale undefined     <-- el sintoma
entrada.js: termina
pedido.js: Entrada vale function
pedido.js: termina

Y al usarlo: TypeError: Right-hand side of 'instanceof' is not callable.

El arreglo elegido: invertir la dependencia.

// src/dominio/entrada.js
// Entrada NO necesita conocer la clase Pedido: le basta con su identificador.

class Entrada {
  constructor({ codigo, sesionId, pedidoId, estado = 'valida' }) {
    this.codigo = codigo;
    this.sesionId = sesionId;
    this.pedidoId = pedidoId;      // Solo el id, no el objeto
    this.estado = estado;
  }

  usar() {
    if (this.estado !== 'valida') {
      const error = new Error(`La entrada ${this.codigo} esta ${this.estado}`);
      error.codigo = 'ENTRADA_NO_VALIDA';
      throw error;
    }
    this.estado = 'usada';
    return this;
  }

  anular() {
    this.estado = 'anulada';
    return this;
  }
}

module.exports = { Entrada };
// src/dominio/pedido.js
// Pedido conoce a Entrada. Entrada no conoce a Pedido. Sin ciclo.

const { Entrada } = require('./entrada.js');
const { generarCodigoEntrada } = require('../utiles/formato.js');

class Pedido {
  #entradas = [];

  constructor({ id, usuarioId, sesionId, cantidad, precioCentimos, estado = 'pendiente' }) {
    this.id = id;
    this.usuarioId = usuarioId;
    this.sesionId = sesionId;
    this.cantidad = cantidad;
    this.totalCentimos = cantidad * precioCentimos;
    this.estado = estado;
  }

  get entradas() {
    return [...this.#entradas];
  }

  emitir(anio, secuenciaInicial) {
    if (this.estado !== 'pagado') {
      const error = new Error(`No se pueden emitir entradas de un pedido ${this.estado}`);
      error.codigo = 'ESTADO_INVALIDO';
      throw error;
    }

    for (let i = 0; i < this.cantidad; i++) {
      this.#entradas.push(new Entrada({
        codigo: generarCodigoEntrada(anio, secuenciaInicial + i),
        sesionId: this.sesionId,
        pedidoId: this.id
      }));
    }

    this.estado = 'emitido';
    return this.entradas;
  }
}

module.exports = { Pedido };

Justificación de la elección. De las cuatro técnicas del apartado 7, invertir la dependencia es la correcta aquí porque el ciclo era un síntoma de un modelado incorrecto, no un problema técnico: una entrada no necesita el objeto Pedido completo, solo su identificador (pedidoId), que es exactamente lo que ya define el modelo de dominio del Módulo 1. Mover el require dentro del método habría funcionado, pero habría dejado el acoplamiento intacto y escondido; extraer a un tercer módulo habría añadido un fichero sin necesidad. El ciclo desapareció porque el diseño mejoró, que es siempre la mejor solución posible a una dependencia circular.

Conclusión

Has cerrado la deuda que arrastrábamos desde la tercera lección del curso. Ahora sabes que cada fichero de Node es un módulo con ámbito propio, y que esa privacidad por defecto —la que permite cambiar los detalles internos sin miedo— se consigue con un truco muy concreto: el envoltorio de módulo, una función que Node añade alrededor de tu código y que le inyecta cinco parámetros: exports, require, module, __filename y __dirname.

Entiendes con precisión la trampa clásica: exports y module.exports apuntan al mismo objeto al principio, así que añadir propiedades a cualquiera de los dos funciona, pero reasignar exports rompe la conexión y deja el módulo exportando un objeto vacío, porque require devuelve module.exports. De ahí la regla que seguiremos siempre: un único module.exports = { ... } al final del fichero, que además funciona como índice de la API pública.

Conoces el algoritmo de resolución: primero módulos del núcleo (mejor con el prefijo node:), después rutas relativas o absolutas con su cascada de extensiones y su interpretación como carpeta con index.js, y por último la búsqueda ascendente en node_modules. Y sabes que un módulo se evalúa una sola vez: es un singleton cacheado por ruta resuelta, lo que es perfecto para un grupo de conexiones o un registrador, y peligroso para configuración mutable o para pruebas que se contaminan entre sí. Sabes también qué devuelve Node ante una dependencia circular —un objeto incompleto al segundo módulo— y que la solución casi siempre es de diseño, no de sintaxis.

Y Escena Viva ha dejado de ser un montón de ficheros con datos copiados. Ahora tiene src/utiles/formato.js con las funciones de presentación que estaban duplicadas, src/dominio/sesion.js y src/dominio/evento.js con las clases del Módulo 1 —y con Evento delegando la validación de aforo en Sesion, que es quien sabe hacerla—, src/dominio/gestor-ventas.js de la lección anterior, src/dominio/index.js como fachada, src/catalogo-datos.js con module.exports y una firma preparada para volverse asíncrona en el Módulo 3, y un src/catalogo.js que ya solo lee argumentos y presenta. Los totales siguen cuadrando: 3000 de aforo, 1811 vendidas, 1189 libres.

Queda una última pieza del módulo, y es una que cambia el paisaje. Todo lo que has aprendido hoy —require, module.exports, la caché, el envoltorio— es CommonJS, el sistema propio de Node. Pero JavaScript acabó teniendo un sistema de módulos estándar, definido en el lenguaje y compartido con el navegador: import y export. No es una sintaxis alternativa para lo mismo: es un modelo estático, que se resuelve antes de ejecutar nada, con reglas distintas sobre extensiones, sin __dirname, sin require… y con await de nivel superior. En la siguiente lección, Módulos ES e Interoperabilidad, verás las dos direcciones de la convivencia entre ambos sistemas, sus límites reales, y escribirás la versión ESM del dominio de Escena Viva lado a lado con la que acabas de terminar.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados