Acabas de dominar CommonJS: require, module.exports, la caché, el envoltorio de módulo. Es el sistema con el que Node.js nació y con el que funciona una parte enorme del código que existe hoy. Pero no es el sistema de módulos de JavaScript.

Mientras Node resolvía el problema por su cuenta en 2009, el comité que estandariza el lenguaje trabajaba en una solución oficial. Llegó en 2015 con ES2015: los módulos ES (ESM), con import y export, definidos dentro del lenguaje y pensados para funcionar igual en el navegador y en el servidor. Node tardó años en soportarlos de forma estable, y hoy convive con los dos.

Esta lección no es un catálogo de sintaxis alternativa. La diferencia entre ambos sistemas es profunda: CommonJS es dinámico y se resuelve mientras el programa corre; ESM es estático y se resuelve antes de ejecutar una sola línea. De ahí salen todas las demás diferencias: por qué las extensiones son obligatorias, por qué __dirname no existe, por qué CommonJS no puede requerir ESM, y por qué solo ESM tiene await de nivel superior.

Al terminar sabrás escribir módulos ES, convertir lo que has construido en Escena Viva, hacer convivir ambos sistemas en un mismo proyecto conociendo los límites reales, y tendrás un criterio claro sobre qué usar y cuándo.

Contenido

  1. La sintaxis de los módulos ES
  2. La diferencia esencial: estático frente a dinámico
  3. Tabla comparativa completa
  4. Cómo se activa ESM en Node
  5. Las extensiones son obligatorias
  6. Lo que no existe en ESM y cómo sustituirlo
  7. await de nivel superior
  8. Importación dinámica: await import()
  9. Interoperabilidad en las dos direcciones
  10. La versión ESM del dominio de Escena Viva
  11. Criterio práctico para el curso

  1. La sintaxis de los módulos ES

Exportaciones nombradas

La forma más habitual, y la equivalente a nuestro module.exports = { ... }:

// src/utiles/formato.mjs

// Opcion A: exportar en la propia declaracion.
export function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  const resto = String(centimos % 100).padStart(2, '0');
  return `${euros},${resto} EUR`;
}

export 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');
  return `${dia}/${mes}/${fecha.getFullYear()}`;
}

export const SIMBOLO_MONEDA = 'EUR';
// Opcion B: exportar en bloque al final. Equivale al module.exports unico
// de CommonJS y tiene la misma ventaja: un indice de la API publica.
function formatearPrecio(centimos) { /* ... */ }
function formatearFecha(fechaISO) { /* ... */ }
const SIMBOLO_MONEDA = 'EUR';

export { formatearPrecio, formatearFecha, SIMBOLO_MONEDA };

Y al importar:

// Importar lo que necesitas, por su nombre.
import { formatearPrecio, formatearFecha } from './utiles/formato.mjs';

// Renombrar, para evitar colisiones.
import { formatearPrecio as precio } from './utiles/formato.mjs';

// Importar TODO en un espacio de nombres.
import * as formato from './utiles/formato.mjs';
console.log(formato.formatearPrecio(2500));

// Importar solo por su efecto secundario (raro, pero existe).
import './configuracion-global.mjs';

Exportación por defecto

Cada módulo puede tener una exportación por defecto:

// src/dominio/sesion.mjs
export default class Sesion {
  /* ... */
}
// Al importar, tu eliges el nombre: no hay llaves.
import Sesion from './dominio/sesion.mjs';
import LoQueSea from './dominio/sesion.mjs';   // Legal, y confuso

Se pueden combinar ambas formas:

// src/dominio/evento.mjs
export default class Evento { /* ... */ }
export const ESTADOS_EVENTO = ['borrador', 'publicado', 'finalizado'];
import Evento, { ESTADOS_EVENTO } from './dominio/evento.mjs';
Nombrada Por defecto
Cuántas por módulo Las que quieras Una
Sintaxis de importación import { X } from ... import X from ...
¿El nombre es fijo? Sí (salvo as) No: lo elige quien importa
Autocompletado del editor Bueno Peor
Refactorizar el nombre Se propaga Hay que revisar a mano

Recomendación para el curso: usa exportaciones nombradas, no export default. Es coherente con el module.exports = { ... } que ya usamos, el editor autocompleta mejor, y evita que el mismo módulo aparezca con cinco nombres distintos en cinco ficheros. Muchas guías de estilo profesionales (incluidas las de Node y de varios proyectos grandes) han llegado a la misma conclusión.

Reexportar: export * from

El equivalente de nuestra fachada index.js:

// src/dominio/index.mjs

// Reexportar TODO lo nombrado de cada modulo.
export * from './sesion.mjs';
export * from './evento.mjs';
export * from './gestor-ventas.mjs';

// O ser selectivo, que suele ser mejor.
export { Sesion } from './sesion.mjs';
export { Evento } from './evento.mjs';
export { GestorDeVentas, UMBRAL_AFORO_BAJO } from './gestor-ventas.mjs';

// Reexportar cambiando el nombre.
export { Sesion as SesionDeEvento } from './sesion.mjs';

Nota importante: export * no reexporta la exportación por defecto. Si un módulo tiene export default, hay que reexportarla explícitamente:

export { default as Sesion } from './sesion.mjs';

Es otra razón para preferir exportaciones nombradas: las fachadas funcionan sin sorpresas.

  1. La diferencia esencial: estático frente a dinámico

Todo lo demás sale de aquí, así que merece la pena entenderlo bien.

CommonJS es dinámico

require es una función normal. Se ejecuta cuando el intérprete llega a ella, y su argumento puede ser cualquier expresión:

// Todo esto es legal en CommonJS.
const modulo = require('./' + nombreDelModulo + '.js');

if (process.env.MODO === 'produccion') {
  registrador = require('./registrador-produccion.js');
}

for (const nombre of ['a', 'b', 'c']) {
  modulos[nombre] = require(`./plugins/${nombre}.js`);
}

function cargarPerezosamente() {
  const pesado = require('./modulo-muy-pesado.js');   // Solo si se llama
  return pesado.procesar();
}

Node no puede saber qué módulos necesita un programa CommonJS sin ejecutarlo. Es flexible y tiene un precio.

ESM es estático

import no es una función: es una declaración del lenguaje. Su ruta debe ser una cadena literal, y las declaraciones solo pueden estar en el nivel superior del módulo.

// TODO esto es un SyntaxError en ESM.
import modulo from './' + nombre + '.mjs';        // Ruta no literal

if (produccion) {
  import registrador from './registrador.mjs';    // No en un bloque
}

function cargar() {
  import pesado from './pesado.mjs';              // No dentro de una funcion
}

Gracias a esa rigidez, el motor puede analizar todo el grafo de dependencias antes de ejecutar nada. El proceso tiene tres fases bien separadas:

flowchart TD
    subgraph ESM["Módulos ES: tres fases"]
        A1["<b>1. Construcción</b><br/>Se leen todos los ficheros y se<br/>analizan sus import/export.<br/>Se construye el grafo completo<br/><i>sin ejecutar nada</i>"]
        A2["<b>2. Instanciación</b><br/>Se reserva espacio para cada<br/>exportación y se conectan las<br/>referencias entre módulos"]
        A3["<b>3. Evaluación</b><br/>Se ejecuta el código de cada<br/>módulo, en orden de dependencias"]
        A1 --> A2 --> A3
    end

    subgraph CJS["CommonJS: una sola fase"]
        B1["<b>Ejecución</b><br/>Se ejecuta el código de arriba abajo.<br/>Cada require encontrado<br/>carga y evalúa en ese momento"]
    end

Las consecuencias de ese análisis previo son muy concretas:

Consecuencia Por qué importa
Errores de importación en tiempo de análisis Importar algo que un módulo no exporta falla antes de ejecutar, no a mitad de una petición en producción
Eliminación de código muerto (tree-shaking) Un empaquetador sabe qué exportaciones no se usan y puede quitarlas. Con require es imposible saberlo
Las importaciones se elevan Todos los import se procesan antes que cualquier código del módulo, estén donde estén escritos
Los enlaces son vivos Un import no copia el valor: enlaza con la variable original

Ese último punto sorprende y conviene verlo:

// src/laboratorio/contador.mjs
export let cuenta = 0;
export function incrementar() {
  cuenta++;
}
// src/laboratorio/usar-contador.mjs
import { cuenta, incrementar } from './contador.mjs';

console.log(cuenta);   // 0
incrementar();
console.log(cuenta);   // 1  <-- El valor importado CAMBIO

Con CommonJS, const { cuenta } = require('./contador.js') habría copiado el 0 y nunca cambiaría. En ESM, cuenta es un enlace vivo de solo lectura a la variable del otro módulo. Puedes leerla y ver sus cambios, pero no asignarle nada (cuenta = 5 es un TypeError).

Y las importaciones se elevan:

// Esto funciona en ESM, aunque parezca imposible.
console.log(formatearPrecio(2500));            // 25,00 EUR
import { formatearPrecio } from './formato.mjs';

El import se procesa en la fase 1, mucho antes de que se ejecute el console.log. Funciona, pero escribe siempre los import al principio: que el lenguaje lo permita no lo hace legible.

  1. Tabla comparativa completa

Aspecto CommonJS Módulos ES
Sintaxis de importación require('./m.js') import { x } from './m.js'
Sintaxis de exportación module.exports = {...} export { x } / export default
Extensión de fichero .js (o .cjs) .mjs, o .js con "type": "module"
Resolución Dinámica, en tiempo de ejecución Estática, antes de ejecutar
Ruta de importación Cualquier expresión Solo cadena literal
¿Se puede importar condicionalmente? Sí No (solo con import() dinámico)
Extensión en rutas relativas Opcional Obligatoria
Carga Síncrona Asíncrona
Valor importado Copia del module.exports Enlace vivo de solo lectura
this en el nivel superior module.exports ({}) undefined
__dirname / __filename Disponibles No existen (usa import.meta.url)
require Disponible No existe (usa createRequire)
import.meta No existe Disponible
await de nivel superior No Sí
Orden de evaluación Al encontrar el require Dependencias primero, en profundidad
Dependencias circulares Objeto incompleto ({}) Enlaces sin inicializar (ReferenceError)
Modo estricto Opcional Siempre activo
Cargar JSON require('./d.json') directo Necesita with { type: 'json' }
Eliminación de código muerto No Sí
Compatible con el navegador No Sí
Puede importar el otro sistema No puede require ESM Sí puede importar CJS

Dos filas merecen comentario extra:

Modo estricto siempre activo. En ESM no hace falta 'use strict': está implícito. Eso significa que asignar a una variable no declarada lanza un error, this en una función suelta es undefined, y los duplicados de parámetros son ilegales. Es un buen valor por defecto.

Dependencias circulares. En CommonJS recibías un objeto vacío y el fallo aparecía más tarde, con un mensaje confuso. En ESM, gracias al análisis estático, obtienes un ReferenceError: Cannot access 'X' before initialization en el punto exacto del problema. Es mucho mejor: un error claro y temprano en lugar de un undefined viajando por tu código.

  1. Cómo se activa ESM en Node

Node necesita saber si un .js es CommonJS o ESM. Hay dos formas de decírselo.

Opción A: la extensión del fichero

Extensión Sistema Cuándo usarla
.mjs Siempre ESM Un fichero ESM en un proyecto CommonJS
.cjs Siempre CommonJS Un fichero CommonJS en un proyecto ESM
.js Depende del package.json más cercano El caso normal

Es explícito y no depende de nada más. Ideal para introducir un fichero suelto del otro sistema.

Opción B: el campo type del package.json

{
  "type": "module"
}

Con esa línea, todos los .js del proyecto pasan a ser módulos ES. Sin ella (o con "type": "commonjs", que es el valor por defecto), son CommonJS.

Aquí solo nos interesa esa línea. El package.json completo —nombre, versión, dependencias, scripts, exports— es el tema del Módulo 5. Por ahora basta con saber que un fichero package.json con ese único campo, en la raíz de tu proyecto, cambia la interpretación de todos los .js.

La regla de resolución

Node busca el package.json más cercano hacia arriba desde el fichero que va a cargar. Eso permite mezclar:

escena-viva/
├── package.json              { "type": "module" }
├── src/
│   ├── catalogo.js           -> ESM (por el package.json de la raiz)
│   ├── dominio/
│   │   └── evento.js         -> ESM
│   └── heredado/
│       ├── package.json      { "type": "commonjs" }
│       └── antiguo.js        -> CommonJS (por SU package.json)
└── herramientas/
    └── migrar.cjs            -> CommonJS (por la extension)

Es el mecanismo que permite migrar un proyecto grande por partes en lugar de todo de golpe.

Comprobarlo

# Un fichero .mjs siempre es ESM
echo 'console.log(typeof require);' > prueba.mjs
node prueba.mjs
# ReferenceError: require is not defined in ES module scope

# El mismo contenido como .cjs
echo 'console.log(typeof require);' > prueba.cjs
node prueba.cjs
# function

  1. Las extensiones son obligatorias

Este es el tropiezo número uno al migrar de CommonJS a ESM.

// CommonJS: las cuatro formas funcionan.
require('./sesion');
require('./sesion.js');
require('./dominio');          // Encuentra dominio/index.js
require('./dominio/index.js');
// ESM: solo las rutas COMPLETAS funcionan.
import { Sesion } from './sesion.js';           // BIEN
import { Sesion } from './sesion';              // ERROR
import { Evento } from './dominio';             // ERROR: no busca index.js
import { Evento } from './dominio/index.js';    // BIEN
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/joan/escena-viva/src/sesion'
imported from /home/joan/escena-viva/src/catalogo.js
Did you mean to import ./sesion.js?

Node incluso te sugiere la corrección, lo cual se agradece.

¿Por qué esta rigidez? Porque ESM está definido para funcionar también en el navegador, donde un import './sesion' obligaría a que el navegador probara sesion, sesion.js, sesion.json… cada una con una petición HTTP de ida y vuelta. Inaceptable. El estándar exige rutas completas y sin ambigüedad.

Dos matices importantes:

  • La regla solo aplica a rutas relativas y absolutas. Los paquetes de node_modules se siguen importando por su nombre: import express from 'express' es correcto, porque el propio paquete declara su punto de entrada.
  • index.js no es especial en ESM. Hay que escribir la ruta completa. Nuestras fachadas pasan a ser import { Evento } from './dominio/index.js'.

Consejo práctico: escribe siempre las extensiones también en tus ficheros CommonJS —como hemos hecho en toda la lección anterior—. El día que migres, la mitad del trabajo ya estará hecho.

  1. Lo que no existe en ESM y cómo sustituirlo

Las cinco variables del envoltorio de módulo que aprendiste en la lección anterior no existen en ESM. No hay envoltorio: un módulo ES es un módulo de verdad, definido en el lenguaje.

No existe Sustituto
__dirname path.dirname(fileURLToPath(import.meta.url))
__filename fileURLToPath(import.meta.url)
require createRequire(import.meta.url) o await import()
module.exports export
exports export
require.main === module import.meta.url === pathToFileURL(process.argv[1]).href
require.cache No hay API pública equivalente

import.meta

ESM aporta un objeto propio con metadatos del módulo actual:

// src/laboratorio/metadatos.mjs
console.log(import.meta.url);
// file:///home/joan/escena-viva/src/laboratorio/metadatos.mjs

Fíjate en que es una URL, no una ruta del sistema de ficheros. Es coherente con el estándar (en el navegador sería https://...), pero significa que hay que convertirla antes de usarla con fs o path.

Recuperar __dirname y __filename

// src/utiles/rutas.mjs
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

// Equivalentes exactos de las variables de CommonJS.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log(__filename);   // /home/joan/escena-viva/src/utiles/rutas.mjs
console.log(__dirname);    // /home/joan/escena-viva/src/utiles

// Uso habitual: una ruta fiable a los datos.
const RUTA_DATOS = join(__dirname, '..', '..', 'datos', 'eventos.json');

En versiones recientes de Node (20.11 y posteriores) hay un atajo:

const __dirname = import.meta.dirname;    // Directamente, sin conversion
const __filename = import.meta.filename;

Comprueba tu versión con node -v antes de usarlo; si tu proyecto debe funcionar en versiones anteriores, usa la forma con fileURLToPath.

Recuperar require

Para los casos en que necesitas un módulo CommonJS que no se deja importar bien:

// src/laboratorio/usar-require.mjs
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

// Ahora funciona como en CommonJS, incluido el JSON.
const catalogo = require('../datos/eventos.json');
const paqueteAntiguo = require('paquete-solo-commonjs');

console.log(`${catalogo.length} eventos`);

createRequire necesita saber desde dónde resolver las rutas relativas, y por eso recibe import.meta.url.

Detectar si eres el programa principal

// src/informes/ocupacion.mjs
import { pathToFileURL } from 'node:url';

const esProgramaPrincipal = import.meta.url === pathToFileURL(process.argv[1]).href;

if (esProgramaPrincipal) {
  // Solo con: node src/informes/ocupacion.mjs
  ejecutarInforme();
}

Menos elegante que require.main === module, pero equivalente. En Node 20.11+ existe también import.meta.main en algunas configuraciones; consulta la documentación de tu versión.

Importar JSON

// Sintaxis de atributos de importacion (Node 20.10+ / 22+).
import catalogo from '../datos/eventos.json' with { type: 'json' };

console.log(catalogo.length);

El atributo with { type: 'json' } es obligatorio y es una medida de seguridad: evita que un servidor malicioso devuelva JavaScript donde esperabas datos. Como alternativa, siempre puedes leer el fichero con fs, que es lo que haremos en Escena Viva a partir del Módulo 3.

  1. await de nivel superior

La ventaja exclusiva de ESM, y una de las más cómodas.

// src/laboratorio/carga.mjs
import { readFile } from 'node:fs/promises';

// await directamente, sin envolver en ninguna funcion async.
const contenido = await readFile('datos/eventos.json', 'utf8');
const catalogo = JSON.parse(contenido);

console.log(`Cargados ${catalogo.length} eventos`);

Compáralo con lo que había que escribir en CommonJS:

// CommonJS: hace falta una funcion envoltorio y su .catch.
const { readFile } = require('node:fs/promises');

async function principal() {
  const contenido = await readFile('datos/eventos.json', 'utf8');
  const catalogo = JSON.parse(contenido);
  console.log(`Cargados ${catalogo.length} eventos`);
}

principal().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

Sus usos naturales:

// 1. Inicializacion asincrona antes de exportar.
const configuracion = JSON.parse(await readFile('./config.json', 'utf8'));
export { configuracion };

// 2. Eleccion de dependencia en tiempo de ejecucion.
const registrador = process.env.NODE_ENV === 'produccion'
  ? await import('./registrador-produccion.mjs')
  : await import('./registrador-desarrollo.mjs');

// 3. Recursos que deben estar listos antes de que nadie use el modulo.
const conexion = await conectarABaseDeDatos();
export { conexion };

Cómo funciona: un módulo con await de nivel superior se convierte en un módulo asíncrono. Los módulos que lo importen esperarán a que termine antes de ejecutarse. Es potente y tiene un coste: si ese await tarda cinco segundos, todos los importadores esperan cinco segundos. Úsalo para inicialización real, no como un atajo cómodo en cualquier fichero.

Y una advertencia: si el await de nivel superior falla, el módulo entero falla al cargarse y la aplicación no arranca. Envuélvelo en try/catch si quieres degradar con elegancia.

  1. Importación dinámica: await import()

import() como función es la vía de escape que devuelve la flexibilidad de require sin renunciar al análisis estático. Devuelve una promesa que se cumple con el espacio de nombres del módulo.

// Funciona tanto en ESM como en CommonJS.
const modulo = await import('./dominio/evento.mjs');
console.log(modulo.Evento);

// Con desestructuracion.
const { Evento } = await import('./dominio/evento.mjs');

Sus tres usos legítimos:

8.1 Carga condicional

// src/laboratorio/carga-condicional.mjs

const modo = process.env.NODE_ENV ?? 'desarrollo';

// La ruta puede ser una expresion: aqui si.
const { registrador } = await import(`./registradores/${modo}.mjs`);

registrador.info('Escena Viva arrancando');

8.2 Carga perezosa de módulos pesados

// El generador de PDF de las entradas pesa mucho y rara vez se usa.
// No queremos cargarlo en el arranque de cada proceso.

export async function generarPdfEntrada(entrada) {
  const { crearPdf } = await import('./pdf/generador.mjs');
  return crearPdf(entrada);
}

Se carga la primera vez que alguien genera un PDF, y a partir de ahí queda cacheado.

8.3 Cargar CommonJS desde ESM (y desde CommonJS, ESM)

Es el puente de interoperabilidad, y lo vemos en el apartado siguiente.

import estático import() dinámico
Ruta Cadena literal Cualquier expresión
Dónde puede aparecer Nivel superior En cualquier sitio
Devuelve Enlaces vivos Una promesa
¿Permite eliminar código muerto? Sí No
¿Funciona en CommonJS? No Sí

No abuses de import(). Cada importación dinámica es una dependencia que las herramientas no pueden analizar. Úsalo cuando tengas una razón (condicionalidad, peso, interoperabilidad), no por costumbre.

  1. Interoperabilidad en las dos direcciones

Aquí está la parte que genera más frustración en proyectos reales, y las reglas no son simétricas.

flowchart LR
    ESM["Módulo ES<br/>(.mjs)"]
    CJS["Módulo CommonJS<br/>(.cjs)"]

    ESM -->|"import ... from<br/><b>SÍ, con matices</b>"| CJS
    CJS -->|"require<br/><b>NO</b>"| ESM
    CJS -.->|"await import()<br/><b>SÍ</b>"| ESM

9.1 ESM importando CommonJS: sí, con matices

// src/utiles/formato.cjs  (CommonJS)
function formatearPrecio(centimos) { /* ... */ }
function formatearFecha(fechaISO) { /* ... */ }

module.exports = { formatearPrecio, formatearFecha };
// src/catalogo.mjs  (ESM)

// El module.exports completo llega como exportacion POR DEFECTO.
import formato from './utiles/formato.cjs';
console.log(formato.formatearPrecio(2500));   // Siempre funciona

// Las exportaciones nombradas funcionan... si Node consigue detectarlas.
import { formatearPrecio } from './utiles/formato.cjs';   // Suele funcionar

La regla exacta: el module.exports de un módulo CommonJS siempre llega como exportación por defecto. Además, Node ejecuta un analizador estático (cjs-module-lexer) sobre el fichero para intentar detectar las exportaciones nombradas y ofrecerlas también. Ese análisis es sintáctico, no ejecuta el código, así que falla en cuanto los exports se construyen de forma dinámica:

// CommonJS con exports dinamicos: el analizador NO puede detectarlos.
const funciones = { formatearPrecio, formatearFecha };
for (const [nombre, fn] of Object.entries(funciones)) {
  module.exports[nombre] = fn;
}
// Desde ESM:
import { formatearPrecio } from './formato.cjs';
// SyntaxError: The requested module './formato.cjs' does not provide
// an export named 'formatearPrecio'

La solución universal y siempre segura:

// Importa el objeto completo por defecto y desestructura despues.
import formato from './utiles/formato.cjs';
const { formatearPrecio, formatearFecha } = formato;

Si un paquete de npm te da ese error al importarlo con llaves, esta es la respuesta.

9.2 CommonJS requiriendo ESM: no

// src/antiguo.cjs
const { Evento } = require('./dominio/evento.mjs');
Error [ERR_REQUIRE_ESM]: require() of ES Module
/home/joan/escena-viva/src/dominio/evento.mjs not supported.
Instead change the require of evento.mjs to a dynamic import() which is
available in all CommonJS modules.

La razón es estructural, no un capricho: require es síncrono —devuelve el valor inmediatamente— y la carga de ESM es asíncrona, porque puede haber await de nivel superior en cualquier punto del grafo de dependencias. No hay forma de que una función síncrona devuelva el resultado de un proceso asíncrono.

La solución es la importación dinámica, que sí funciona en CommonJS:

// src/antiguo.cjs
async function principal() {
  const { Evento } = await import('./dominio/evento.mjs');

  const evento = new Evento({ id: 'evt-001', titulo: 'Concierto de Otono' });
  console.log(evento.toString());
}

principal().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

El precio: la función que lo hace tiene que volverse asíncrona, y esa asincronía se propaga hacia arriba. Es lo que se conoce como el problema de la "coloración de funciones", y es la causa de que migrar un proyecto grande de CommonJS a ESM sea más trabajoso de lo que parece.

Nota sobre versiones recientes. Node 22 introdujo require() de módulos ES síncronos (sin await de nivel superior) tras un indicador experimental, y se ha ido estabilizando en versiones posteriores. Es una mejora real para la migración, pero no la des por hecha: depende de la versión y el módulo importado no puede contener await de nivel superior en ningún punto de su grafo. La regla general que debes tener en la cabeza sigue siendo la de arriba.

9.3 Resumen de la interoperabilidad

Desde Hacia ¿Funciona? Cómo
ESM CommonJS Sí import x from './m.cjs' (por defecto: siempre)
ESM CommonJS, exports nombrados Casi siempre Depende del análisis estático; si falla, importa por defecto
ESM ESM Sí import { x } from './m.mjs'
CommonJS ESM con require No ERR_REQUIRE_ESM (salvo casos recientes y limitados)
CommonJS ESM con import() Sí const m = await import('./m.mjs')
CommonJS CommonJS Sí require('./m.cjs')

  1. La versión ESM del dominio de Escena Viva

Vamos a traducir lo que construiste en la lección anterior. Verás que la lógica no cambia en absoluto: solo cambian las líneas de entrada y salida.

src/utiles/formato.js

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

function formatearFecha(fechaISO) { /* ... */ }
function generarCodigoEntrada(anio, secuencia) { /* ... */ }

module.exports = { formatearPrecio, formatearFecha, generarCodigoEntrada };
// ---------- Módulos ES ----------
export function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  const resto = String(centimos % 100).padStart(2, '0');
  return `${euros},${resto} EUR`;
}

export function formatearFecha(fechaISO) { /* ... */ }
export function generarCodigoEntrada(anio, secuencia) { /* ... */ }

Sin línea final: cada función se exporta donde se declara.

src/dominio/sesion.js

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

class Sesion {
  #vendidas = 0;
  /* ... el cuerpo entero, sin un solo cambio ... */
}

module.exports = { Sesion };
// ---------- Módulos ES ----------
import { formatearPrecio, formatearFecha } from '../utiles/formato.js';

export class Sesion {
  #vendidas = 0;
  /* ... el cuerpo entero, sin un solo cambio ... */
}

Los campos privados, los getters, toJSON, la validación: todo idéntico. El sistema de módulos no toca la lógica.

src/dominio/gestor-ventas.js

// ---------- CommonJS ----------
const EventEmitter = require('node:events');

const UMBRAL_AFORO_BAJO = 0.10;

class GestorDeVentas extends EventEmitter { /* ... */ }

module.exports = { GestorDeVentas, UMBRAL_AFORO_BAJO };
// ---------- Módulos ES ----------
import { EventEmitter } from 'node:events';

export const UMBRAL_AFORO_BAJO = 0.10;

export class GestorDeVentas extends EventEmitter { /* ... */ }

Un detalle real que merece atención: en CommonJS escribíamos require('node:events') y usábamos el resultado directamente como clase, porque el módulo events exporta la clase como su module.exports. En ESM, la forma recomendada es la importación nombrada import { EventEmitter } from 'node:events'. Ambas funcionan (el módulo ofrece las dos), pero la nombrada es más explícita y es la que verás en documentación moderna.

src/dominio/index.js

// ---------- CommonJS ----------
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 };
// ---------- Módulos ES ----------
export { Sesion } from './sesion.js';
export { Evento } from './evento.js';
export { GestorDeVentas, UMBRAL_AFORO_BAJO } from './gestor-ventas.js';

La versión ESM es más corta y más clara: reexporta directamente, sin necesidad de importar primero para volver a exportar después. Es una de las mejoras genuinas de la sintaxis.

src/catalogo-datos.js

// ---------- CommonJS ----------
const catalogo = [ /* los 3 eventos */ ];

function obtenerCatalogo() {
  return structuredClone(catalogo);
}

function obtenerEventoPorId(id) { /* ... */ }

module.exports = { obtenerCatalogo, obtenerEventoPorId };
// ---------- Módulos ES ----------
// El array queda PRIVADO: al no exportarlo, nadie fuera puede tocarlo.
const catalogo = [ /* los 3 eventos */ ];

export function obtenerCatalogo() {
  return structuredClone(catalogo);
}

export function obtenerEventoPorId(id) { /* ... */ }

src/catalogo.js

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

function principal() { /* ... */ }

if (require.main === module) {
  principal();
}

module.exports = { leerOpciones, principal };
// ---------- Módulos ES ----------
import { pathToFileURL } from 'node:url';
import { obtenerCatalogo } from './catalogo-datos.js';
import { Evento } from './dominio/index.js';              // Extension OBLIGATORIA
import { formatearPrecio } from './utiles/formato.js';

export function principal() { /* ... */ }

// Equivalente de require.main === module
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  principal();
}

Los tres cambios reales de este fichero, y son los tres que verás siempre al migrar:

  1. ./dominio → ./dominio/index.js: la extensión y el index.js explícito.
  2. require.main === module → la comparación de URL.
  3. module.exports al final → export en cada declaración.

La versión ESM que aprovecha await de nivel superior

Y aquí una ventaja real, no cosmética. Adelantando lo que haremos en el Módulo 3, así quedará la carga del catálogo desde disco:

// src/catalogo-datos.mjs  (adelanto del modulo 3)
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const RUTA_DATOS = join(__dirname, '..', 'datos', 'eventos.json');

// await de nivel superior: el catalogo se carga UNA VEZ, al importar el modulo.
// Quien importe este modulo recibira los datos ya listos.
const catalogo = JSON.parse(await readFile(RUTA_DATOS, 'utf8'));

export function obtenerCatalogo() {
  return structuredClone(catalogo);
}

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

Esto no tiene equivalente en CommonJS. Allí tendrías que elegir entre una lectura síncrona bloqueante (readFileSync) o exportar una promesa que cada consumidor tendría que esperar. Con ESM, la inicialización asíncrona ocurre en la carga del módulo, transparente para quien lo usa.

  1. Criterio práctico para el curso

Qué usaremos

En el resto del curso seguiremos usando CommonJS como sistema principal, y estas son las razones:

  1. Es lo que te vas a encontrar. Millones de proyectos, incontables tutoriales y una parte enorme de npm siguen siendo CommonJS. Saber leerlo no es opcional.
  2. Menos fricción para aprender. require.main === module es más simple que comparar URLs, __dirname está ahí sin ceremonias, y no hay que pensar en interoperabilidad mientras aprendes fs o Express.
  3. Express y buena parte del ecosistema clásico documentan sus ejemplos en CommonJS.

Pero cada vez que un tema tenga una versión ESM relevante, la mostraremos, y en el Módulo 12 haremos la migración completa de Escena Viva como ejercicio de cierre.

Qué usar en tus proyectos nuevos

Situación Recomendación
Proyecto nuevo desde cero ESM. Es el estándar del lenguaje y el futuro
Proyecto CommonJS existente y grande Quedarse en CommonJS, o migrar por partes con .mjs
Biblioteca que vas a publicar en npm Publicar ambos formatos (el campo exports del Módulo 5)
Código que también corre en el navegador ESM, sin discusión
Script rápido con await en el nivel superior ESM (o un .mjs suelto)
Herramienta de configuración de otro proyecto Lo que ese proyecto espere

Por qué el ecosistema convive con los dos

No es un accidente ni pereza colectiva. Hay razones de fondo:

  • La compatibilidad hacia atrás es un valor. Node no puede romper millones de proyectos en producción de un día para otro.
  • La migración es contagiosa. Si tu módulo pasa a ESM, quienes lo consumían desde CommonJS ya no pueden usar require. La presión se propaga hacia arriba en el árbol de dependencias.
  • Muchas herramientas esperan CommonJS. Ficheros de configuración, complementos y algunos ejecutores de pruebas siguen asumiéndolo.
  • La solución de las bibliotecas es publicar los dos formatos (dual package), a costa de una configuración más compleja y de un riesgo real: cargar dos copias de la misma biblioteca, una por cada sistema, con estados independientes.

La transición lleva casi una década y le quedan años. La habilidad profesional no es elegir un bando: es saber trabajar con los dos y reconocer al instante cuál tienes delante.

Errores Comunes y Consejos

Error 1: omitir la extensión en un import relativo. ERR_MODULE_NOT_FOUND. Es el tropiezo número uno al migrar. Siempre ./sesion.js.

Error 2: esperar que ./dominio encuentre index.js. En ESM no hay resolución de carpeta. Escribe ./dominio/index.js.

Error 3: usar __dirname en un fichero ESM. ReferenceError. Usa import.meta.dirname o fileURLToPath(import.meta.url).

Error 4: require() de un módulo ES. ERR_REQUIRE_ESM. Usa await import() y asume que la función pasa a ser asíncrona.

Error 5: importar con llaves de un paquete CommonJS y que falle. El analizador no detectó las exportaciones nombradas. Importa por defecto y desestructura.

Error 6: añadir "type": "module" a un proyecto existente sin más. Todos los .js cambian de sistema a la vez y todo se rompe. Migra por partes con .mjs, o renombra a .cjs lo que deba seguir igual.

Error 7: await de nivel superior en un módulo importado por muchos otros. Todos esperan a que termine. Resérvalo para inicialización real.

Error 8: abusar de import() dinámico. Pierdes el análisis estático y la eliminación de código muerto. Solo con una razón concreta.

Consejo 1: escribe las extensiones también en CommonJS. Cuando migres, ese trabajo ya estará hecho.

Consejo 2: usa exportaciones nombradas, no export default. Mejor autocompletado, nombres coherentes y fachadas sin sorpresas.

Consejo 3: aprende a reconocer el sistema en tres segundos. ¿Ves import? ESM. ¿Ves require? CommonJS. ¿Es un .js? Mira el package.json más cercano.

Consejo 4: cuando un paquete de npm te dé problemas de importación, mira su package.json. El campo exports (Módulo 5) y el campo type te dicen exactamente qué ofrece.

Ejercicios

Ejercicio 1: migrar el dominio a ESM

Crea una copia del proyecto en escena-viva-esm/ y migra a módulos ES todo lo construido en la lección anterior:

  1. Un package.json con únicamente { "type": "module" }.
  2. src/utiles/formato.js, src/dominio/sesion.js, src/dominio/evento.js, src/dominio/gestor-ventas.js, src/dominio/index.js, src/catalogo-datos.js y src/catalogo.js.
  3. Todas las rutas relativas con su extensión, y ./dominio/index.js explícito.
  4. require.main === module sustituido por la comparación con import.meta.url.
  5. src/informes/ocupacion.js migrado y ejecutable directamente.

Verifica que node src/catalogo.js --tabla produce exactamente la misma salida que la versión CommonJS, con los mismos totales (3000 de aforo, 1811 vendidas, 1189 libres). Anota cuántos cambios has tenido que hacer y de qué tipo.

Ejercicio 2: interoperabilidad en las dos direcciones

Crea escena-viva-mixto/ con un proyecto que demuestre experimentalmente las reglas del apartado 9:

  1. src/utiles/formato.cjs — CommonJS, con module.exports = { formatearPrecio, formatearFecha }.
  2. src/utiles/legado-dinamico.cjs — CommonJS que construye sus exports dinámicamente en un bucle.
  3. src/dominio/sesion.mjs — ESM que importa formato.cjs (que funcione) e intenta importar con llaves de legado-dinamico.cjs (que falle), con la solución aplicada.
  4. src/informe.cjs — CommonJS que necesita la clase Sesion del fichero ESM. Demuestra primero que require falla (captura el error y muestra su code) y después resuélvelo con await import().
  5. Un README.md con una tabla de qué combinación funciona, cuál no y por qué.

Ejercicio 3: el cargador de catálogo con await de nivel superior

Escribe, en un proyecto ESM, src/catalogo-datos.mjs que aproveche de verdad lo exclusivo de ESM:

  1. Cargue datos/eventos.json con readFile de node:fs/promises y await de nivel superior, usando import.meta.url para construir una ruta fiable.
  2. Trate el fallo de lectura: si el fichero no existe, que registre un aviso claro por stderr y siga con un catálogo vacío en lugar de impedir el arranque de la aplicación.
  3. Exporte obtenerCatalogo(), obtenerEventoPorId(id) y obtenerSesion(sesionId), todas devolviendo copias.
  4. Exporte también una constante CARGADO_EN con la marca de tiempo ISO del momento de la carga, y demuestre —importándolo desde tres ficheros distintos— que el valor es el mismo en los tres: la caché de módulos también existe en ESM.
  5. Un src/principal.mjs que use todo lo anterior con await de nivel superior, sin ninguna función principal() envolvente.

Responde: ¿qué pasaría si el await de nivel superior tardara 3 segundos? ¿Y si lanzara una excepción no capturada?

Soluciones

Solución 1

// escena-viva-esm/package.json
{
  "type": "module"
}
// src/utiles/formato.js
export function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  const resto = String(centimos % 100).padStart(2, '0');
  return `${euros},${resto} EUR`;
}

export 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}`;
}

export function generarCodigoEntrada(anio, secuencia) {
  return `EV-${anio}-${String(secuencia).padStart(6, '0')}`;
}
// src/dominio/sesion.js
import { formatearPrecio, formatearFecha } from '../utiles/formato.js';

export class Sesion {
  #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); }
  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;
  }

  describir() {
    return (
      `${formatearFecha(this.fechaHora)}  ${formatearPrecio(this.precioCentimos)}  ` +
      `${this.libres}/${this.aforo} libres  (${this.ocupacion}% ocupado)` +
      (this.agotada ? '  [AGOTADA]' : '')
    );
  }

  toJSON() {
    return {
      id: this.id,
      fechaHora: this.fechaHora,
      aforo: this.aforo,
      vendidas: this.#vendidas,
      precioCentimos: this.precioCentimos
    };
  }
}
// src/dominio/index.js
export { Sesion } from './sesion.js';
export { Evento } from './evento.js';
export { GestorDeVentas, UMBRAL_AFORO_BAJO } from './gestor-ventas.js';
// src/catalogo.js  (solo las partes que cambian)
import { pathToFileURL } from 'node:url';
import { obtenerCatalogo } from './catalogo-datos.js';
import { Evento } from './dominio/index.js';
import { formatearPrecio } from './utiles/formato.js';

export function leerOpciones(argumentos) { /* identico */ }

export function principal() { /* identico */ }

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  principal();
}
node src/catalogo.js --tabla
# Salida IDENTICA a la version CommonJS
# 3 eventos | aforo 3000 | vendidas 1811 | libres 1189 | recaudacion 62298,00 EUR

Inventario de cambios. Migrar siete ficheros exigió exactamente cuatro tipos de cambio, y ninguno tocó la lógica:

Tipo de cambio Cuántas veces Detalle
const {...} = require(...) → import {...} from ... 9 Mecánico
module.exports = {...} → export en la declaración 7 Uno por fichero
require('./dominio') → './dominio/index.js' 2 La resolución de carpeta no existe
require.main === module → comparación de URL 2 Más import { pathToFileURL }

Cero cambios en clases, campos privados, getters, validaciones, cálculos o formato. Es la demostración práctica de que el sistema de módulos es infraestructura: cambiarlo no debería tocar tu lógica de negocio, y si la toca, es que estaban demasiado acoplados.

Solución 2

// src/utiles/formato.cjs
// CommonJS con exports ESTATICOS: el analizador de Node los detecta.
function formatearPrecio(centimos) {
  const euros = Math.floor(centimos / 100);
  return `${euros},${String(centimos % 100).padStart(2, '0')} EUR`;
}

function formatearFecha(fechaISO) {
  return fechaISO.slice(0, 10).split('-').reverse().join('/');
}

module.exports = { formatearPrecio, formatearFecha };
// src/utiles/legado-dinamico.cjs
// CommonJS con exports DINAMICOS: el analizador NO puede detectarlos.
function calcularOcupacion(sesion) {
  return Math.round((sesion.vendidas / sesion.aforo) * 100);
}

function calcularLibres(sesion) {
  return sesion.aforo - sesion.vendidas;
}

// Construccion dinamica: solo se sabe en tiempo de ejecucion.
const funciones = { calcularOcupacion, calcularLibres };
for (const [nombre, fn] of Object.entries(funciones)) {
  module.exports[nombre] = fn;
}
// src/dominio/sesion.mjs
// ESM importando CommonJS en sus dos variantes.

// 1. Exports estaticos: la importacion nombrada FUNCIONA.
import { formatearPrecio } from '../utiles/formato.cjs';

// 2. Exports dinamicos: la nombrada FALLARIA.
//    import { calcularOcupacion } from '../utiles/legado-dinamico.cjs';
//    SyntaxError: does not provide an export named 'calcularOcupacion'
//
//    Solucion universal: importar por defecto y desestructurar.
import legado from '../utiles/legado-dinamico.cjs';
const { calcularOcupacion, calcularLibres } = legado;

export class Sesion {
  constructor({ id, aforo, vendidas = 0, precioCentimos }) {
    this.id = id;
    this.aforo = aforo;
    this.vendidas = vendidas;
    this.precioCentimos = precioCentimos;
  }

  describir() {
    return (
      `${this.id}  ${formatearPrecio(this.precioCentimos)}  ` +
      `${calcularLibres(this)} libres  (${calcularOcupacion(this)}%)`
    );
  }
}
// src/informe.cjs
// CommonJS que necesita una clase definida en un modulo ES.

// 1. Demostracion de que require FALLA.
try {
  const { Sesion } = require('./dominio/sesion.mjs');
  console.log('Esto no se imprime:', Sesion);
} catch (error) {
  console.error(`require fallo con codigo: ${error.code}`);
  console.error(`  ${error.message.split('\n')[0]}`);
}

// 2. Solucion con importacion dinamica.
async function principal() {
  const { Sesion } = await import('./dominio/sesion.mjs');

  const sesion = new Sesion({
    id: 'ses-002-1', aforo: 120, vendidas: 118, precioCentimos: 1800
  });

  console.log('');
  console.log('Con await import() SI funciona:');
  console.log(`  ${sesion.describir()}`);
}

principal().catch((error) => {
  console.error(`Fallo: ${error.message}`);
  process.exitCode = 1;
});
node src/informe.cjs
require fallo con codigo: ERR_REQUIRE_ESM
  require() of ES Module /home/joan/escena-viva-mixto/src/dominio/sesion.mjs not supported.

Con await import() SI funciona:
  ses-002-1  18,00 EUR  2 libres  (98%)

Tabla del README.md:

Desde Hacia Sintaxis ¿Funciona? Motivo
.mjs .cjs con exports estáticos import { x } from Sí El analizador detecta los nombres
.mjs .cjs con exports dinámicos import { x } from No El analizador es sintáctico, no ejecuta el código
.mjs .cjs con exports dinámicos import m from + desestructurar Sí El module.exports completo siempre llega como default
.cjs .mjs require() No require es síncrono; la carga de ESM es asíncrona
.cjs .mjs await import() Sí Devuelve una promesa, compatible con la carga asíncrona

Solución 3

// src/catalogo-datos.mjs
// Carga del catalogo con await de nivel superior. Exclusivo de ESM.

import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const RUTA_DATOS = join(__dirname, '..', 'datos', 'eventos.json');

// 1 y 2. Carga con await de nivel superior y degradacion elegante ante el fallo.
let catalogo = [];

try {
  const contenido = await readFile(RUTA_DATOS, 'utf8');
  catalogo = JSON.parse(contenido);
  console.error(`[catalogo] cargados ${catalogo.length} eventos desde ${RUTA_DATOS}`);
} catch (error) {
  if (error.code === 'ENOENT') {
    console.error(`[catalogo] AVISO: no se encontro ${RUTA_DATOS}. Catalogo vacio.`);
  } else if (error instanceof SyntaxError) {
    console.error(`[catalogo] AVISO: el fichero no es JSON valido (${error.message}).`);
  } else {
    console.error(`[catalogo] AVISO: fallo de lectura (${error.message}).`);
  }
  // No relanzamos: la aplicacion arranca con el catalogo vacio.
}

// 4. Marca de tiempo del momento de la carga.
export const CARGADO_EN = new Date().toISOString();

// 3. Acceso a los datos, siempre con copias.
export function obtenerCatalogo() {
  return structuredClone(catalogo);
}

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

export function obtenerSesion(sesionId) {
  for (const evento of catalogo) {
    const sesion = evento.sesiones.find((s) => s.id === sesionId);
    if (sesion) {
      return structuredClone({ evento: { id: evento.id, titulo: evento.titulo }, sesion });
    }
  }
  return undefined;
}
// src/consumidor-a.mjs
import { CARGADO_EN, obtenerCatalogo } from './catalogo-datos.mjs';

export function informar() {
  return { modulo: 'consumidor-a', cargadoEn: CARGADO_EN, eventos: obtenerCatalogo().length };
}
// src/consumidor-b.mjs
import { CARGADO_EN, obtenerEventoPorId } from './catalogo-datos.mjs';

export function informar() {
  const evento = obtenerEventoPorId('evt-002');
  return { modulo: 'consumidor-b', cargadoEn: CARGADO_EN, titulo: evento?.titulo ?? '(sin datos)' };
}
// src/principal.mjs
// Sin funcion envolvente: await de nivel superior en todo el fichero.

import { setTimeout as dormir } from 'node:timers/promises';
import { CARGADO_EN, obtenerCatalogo, obtenerSesion } from './catalogo-datos.mjs';
import { informar as informarA } from './consumidor-a.mjs';
import { informar as informarB } from './consumidor-b.mjs';

const eventos = obtenerCatalogo();

console.log('CATALOGO');
console.table(eventos.map((e) => ({
  id: e.id,
  titulo: e.titulo,
  sala: e.sala,
  sesiones: e.sesiones.length,
  aforo: e.sesiones.reduce((t, s) => t + s.aforo, 0),
  vendidas: e.sesiones.reduce((t, s) => t + s.vendidas, 0)
})));

const encontrada = obtenerSesion('ses-002-1');
console.log('');
console.log(`Sesion ses-002-1: ${encontrada.evento.titulo}, ` +
  `${encontrada.sesion.aforo - encontrada.sesion.vendidas} libres`);

// Pausa con await de nivel superior: imposible en CommonJS.
await dormir(50);

// 4. La marca de tiempo es la MISMA en los tres modulos.
console.log('');
console.log('CACHE DE MODULOS EN ESM');
console.table([
  { modulo: 'principal', cargadoEn: CARGADO_EN, dato: `${eventos.length} eventos` },
  { ...informarA(), dato: `${informarA().eventos} eventos` },
  { ...informarB(), dato: informarB().titulo }
]);

const todasIguales = CARGADO_EN === informarA().cargadoEn && CARGADO_EN === informarB().cargadoEn;
console.log('');
console.log(`¿Misma marca de tiempo en los tres modulos? ${todasIguales}`);
[catalogo] cargados 3 eventos desde /home/joan/escena-viva-esm/datos/eventos.json
CATALOGO
┌─────────┬───────────┬────────────────────────────────┬────────────────────┬──────────┬───────┬──────────┐
│ (index) │ id        │ titulo                         │ sala               │ sesiones │ aforo │ vendidas │
├─────────┼───────────┼────────────────────────────────┼────────────────────┼──────────┼───────┼──────────┤
│ 0       │ 'evt-001' │ 'Concierto de Otono'           │ 'Teatro Almendra'  │ 2        │ 840   │ 276      │
│ 1       │ 'evt-002' │ 'Noche de Monologos'           │ 'Sala Boveda'      │ 3        │ 360   │ 175      │
│ 2       │ 'evt-003' │ 'Festival de Jazz de Primavera'│ 'Auditorio Ribera' │ 2        │ 1800  │ 1360     │
└─────────┴───────────┴────────────────────────────────┴────────────────────┴──────────┴───────┴──────────┘

Sesion ses-002-1: Noche de Monologos, 2 libres

CACHE DE MODULOS EN ESM
┌─────────┬───────────────┬────────────────────────────┬──────────────────────┐
│ (index) │ modulo        │ cargadoEn                  │ dato                 │
├─────────┼───────────────┼────────────────────────────┼──────────────────────┤
│ 0       │ 'principal'   │ '2026-08-11T18:42:07.331Z' │ '3 eventos'          │
│ 1       │ 'consumidor-a'│ '2026-08-11T18:42:07.331Z' │ '3 eventos'          │
│ 2       │ 'consumidor-b'│ '2026-08-11T18:42:07.331Z' │ 'Noche de Monologos' │
└─────────┴───────────────┴────────────────────────────┴──────────────────────┘

¿Misma marca de tiempo en los tres modulos? true

Respuestas a las preguntas:

  • Si el await de nivel superior tardara 3 segundos, los tres módulos que importan catalogo-datos.mjs esperarían esos 3 segundos antes de ejecutar su primera línea, y principal.mjs no arrancaría hasta entonces. La espera se propaga por todo el grafo de dependencias. Por eso el await de nivel superior debe reservarse para inicialización imprescindible: si el catálogo pudiera cargarse perezosamente o en segundo plano, sería mejor exportar una función async cargarCatalogo() y dejar que cada quien decida cuándo esperar.
  • Si el await lanzara una excepción no capturada, el módulo entero fallaría al evaluarse. Como los módulos ES se instancian antes de ejecutar nada, la aplicación no arrancaría en absoluto: el error se propagaría a todos los importadores y el proceso moriría con ERR_MODULE_NOT_FOUND o el error original. De ahí el try/catch de la solución: convierte un fallo fatal de arranque en un aviso por stderr y un catálogo vacío. La aplicación arranca, informa del problema y funciona en modo degradado, que casi siempre es preferible a no arrancar.

Y la última conclusión, visible en la tabla: la caché de módulos también existe en ESM. Los tres módulos comparten la misma marca de tiempo porque catalogo-datos.mjs se evaluó una sola vez. El mecanismo es distinto por dentro —un registro de módulos por URL en lugar de require.cache— pero la propiedad observable es idéntica: un módulo es un singleton en su proceso.

Conclusión

Has cerrado el sistema de módulos. Ahora conoces los módulos ES: export nombrado y por defecto, import con sus variantes, export * from para las fachadas, y la recomendación de preferir siempre las exportaciones nombradas por autocompletado, coherencia y reexportación sin sorpresas.

Sobre todo, entiendes la diferencia que lo explica todo: CommonJS es dinámico —require es una función que se ejecuta cuando el intérprete llega a ella, con la ruta que sea— mientras que ESM es estático: las importaciones son declaraciones con rutas literales, y el motor construye el grafo completo, lo instancia y solo entonces lo evalúa. De ahí salen todas las consecuencias: errores de importación detectados antes de ejecutar, eliminación de código muerto, importaciones elevadas, enlaces vivos en lugar de copias, y await de nivel superior.

Sabes activarlo de las dos formas —"type": "module" en el package.json, o las extensiones .mjs y .cjs, que permiten migrar un proyecto por partes— y conoces las reglas nuevas: las extensiones son obligatorias en rutas relativas, index.js deja de ser especial, el modo estricto está siempre activo, y las cinco variables del envoltorio desaparecen, sustituidas por import.meta.url con fileURLToPath para __dirname, createRequire para require y pathToFileURL(process.argv[1]) para detectar el programa principal.

Dominas la interoperabilidad y sus asimetrías: ESM puede importar CommonJS —siempre por defecto, y con exportaciones nombradas cuando el analizador estático consigue detectarlas— pero CommonJS no puede require de ESM, porque require es síncrono y la carga de ESM es asíncrona; la vía es await import(), con el coste de volver asíncrona la función que lo hace.

Y has traducido Escena Viva entera. El inventario del ejercicio fue revelador: cuatro tipos de cambio mecánicos, cero cambios en la lógica. Las clases Sesion y Evento, sus campos privados, sus getters, el GestorDeVentas con sus eventos: todo idéntico. El sistema de módulos es infraestructura, y una lógica bien separada no se entera de que cambia.

Con esto cerramos el Módulo 2, el más conceptual del curso. Ya no ves Node como una caja negra: conoces sus capas —V8, libuv, los bindings, la biblioteca estándar—, el thread pool de cuatro hilos y por qué bloquear el hilo principal detiene el servidor entero; puedes predecir línea a línea el orden de ejecución de cualquier programa asíncrono recorriendo las seis fases del bucle de eventos y sus dos colas prioritarias; manejas las tres formas de asincronía —callbacks error-first, promesas con async/await, y eventos con EventEmitter— y sabes cuándo toca cada una; y entiendes los dos sistemas de módulos con los que convive el ecosistema.

Escena Viva ha dejado de ser un script con datos incrustados. Tiene src/dominio/ con Sesion, Evento y GestorDeVentas, una fachada en index.js, utilidades de formato sin duplicar, una capa de datos en catalogo-datos.js con una firma ya preparada para volverse asíncrona, y un catalogo.js que solo lee argumentos y presenta. Los totales siguen siendo los de la semilla: 3000 de aforo, 1811 vendidas, 1189 libres.

Y ahí está la última pieza pendiente, la que se lleva prometiendo desde el Módulo 1: el catálogo todavía vive dentro del código. El fichero datos/eventos.json existe desde la primera lección y aún no lo hemos leído ni una sola vez desde la aplicación. En el Módulo 3: Sistema de Archivos y E/S eso cambia. Aprenderás a leer y escribir ficheros con fs, a construir rutas que funcionen en cualquier sistema operativo con path, y a procesar datos/ventas.csv con streams sin cargarlo entero en memoria —porque el fichero de ventas de una temporada no cabe en el montículo de V8—. Todo lo que has aprendido aquí sobre el bucle de eventos, las promesas y los EventEmitter deja de ser teoría en ese momento: fs.promises son promesas, los streams son EventEmitter, y la diferencia entre readFile y readFileSync es exactamente la diferencia entre un servidor que responde y uno que se congela.

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