Arma las capas de EventoUAB desde cero, en Node.js
En la guía anterior viste el diagrama y el código ya hecho. Acá lo construyes tú, desde la carpeta vacía: qué comandos escribir, qué archivos crear, en qué carpeta va cada uno y cómo comprobar en cada paso que todo funciona. Al terminar tendrás un proyecto real con el dominio protegido, pruebas que corren sin base de datos y un adaptador de SQLite.
👁 — vistas · Ing. Roy Carrasco, Facultad de Ingeniería de Sistemas · UAB
Si todavía no viste la guía de arquitectura por capas, léela primero: acá no se vuelve a explicar el porqué, solo el cómo.
0. Qué necesitas antes de empezar
Tres cosas instaladas y una terminal abierta.
| Herramienta | Para qué | Cómo comprobarla |
|---|---|---|
| Node.js 22 o superior (LTS) | Ejecutar JavaScript fuera del navegador. Trae npm y un ejecutor de pruebas incluidos. | Escribe node -v en la terminal. |
| Un editor de código | Crear los archivos. Sirve Visual Studio Code. | Que abra una carpeta y muestre su contenido. |
| Una terminal | Escribir comandos. En Windows, PowerShell; en macOS o Linux, Terminal. | Que responda a node -v con un número de versión. |
node -v
npm -v
Este proyecto no usa Express ni ninguna otra librería web, a propósito: con una librería de más, la lección se mezcla con otra cosa. La única dependencia externa será la de SQLite, y solo en el último paso.
1. Crear la carpeta y el proyecto
Un proyecto de Node es una carpeta con un archivo package.json que describe qué es y qué comandos tiene. Lo creamos desde la terminal.
mkdir eventouab
cd eventouab
npm init -y
El comando npm init -y crea el archivo con valores por defecto. Ábrelo en tu editor y déjalo así, con dos cambios importantes: "type": "module" (para poder usar import y export) y los dos scripts.
{
"name": "eventouab",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "node --test test/*.test.js",
"start": "node src/main.js"
},
"dependencies": {
"better-sqlite3": "^13.0.3"
}
}
Sin esa línea, Node trata los archivos .js como el formato antiguo y falla apenas ve un import. Es el error más común del taller (mira la tabla de errores al final).
2. Crear las carpetas: una por capa
La estructura de carpetas es la arquitectura hecha visible: quien abre el proyecto debe ver las capas sin leer ningún archivo.
mkdir src\dominio, src\infraestructura, test
mkdir -p src/dominio src/infraestructura test
eventouab/
├── package.json
├── src/
│ ├── dominio/ ← el centro: entidades, reglas y puertos
│ │ ├── reserva.js
│ │ ├── puertos.js
│ │ └── reservarEspacio.js
│ ├── infraestructura/ ← los detalles que pueden cambiar
│ │ └── repoSqlite.js
│ └── main.js ← arma las piezas y las pone a andar
└── test/
├── repoEnMemoria.js ← adaptador de pruebas
├── reservarEspacio.test.js
└── arquitectura.test.js
| Carpeta | Qué va adentro | Qué puede importar |
|---|---|---|
src/dominio | Entidades, reglas del negocio, casos de uso y puertos. | Solo archivos de su propia carpeta. |
src/infraestructura | Código que habla con la base de datos u otros servicios. | El dominio y las librerías externas. |
test | Pruebas y adaptadores que solo sirven para probar. | Cualquier cosa del proyecto. |
src/main.js | El punto de entrada: elige qué adaptador usar y arranca. | Todo. Es el único que conoce todas las piezas. |
Es el lugar donde se decide, por ejemplo, «usar SQLite» o «usar memoria». Esa decisión no corresponde al dominio ni a la infraestructura: por eso vive afuera de las dos carpetas.
3. El dominio: tres archivos que no importan nada de afuera
Crea cada archivo dentro de src/dominio y copia su contenido. Para cada uno, fíjate en qué import tiene (o no).
3.1 La entidad. src/dominio/reserva.js no tiene ningún import. Además de guardar datos, defiende una regla: el fin debe ser posterior al inicio.
export class Reserva {
constructor(clubId, espacioId, inicio, fin) {
if (fin <= inicio) throw new Error("El fin debe ser posterior al inicio");
this.clubId = clubId;
this.espacioId = espacioId;
this.inicio = inicio;
this.fin = fin;
}
}
3.2 El puerto. src/dominio/puertos.js declara lo que el dominio necesita de afuera, sin decir cómo se hace. JavaScript no tiene interfaces, así que usamos una clase base cuyos métodos fallan si nadie los implementa.
export class RepositorioDeReservas {
hayConflicto(espacioId, inicio, fin) { throw new Error("no implementado"); }
guardar(reserva) { throw new Error("no implementado"); }
}
3.3 El caso de uso. src/dominio/reservarEspacio.js importa solo a reserva.js, que está en la misma carpeta. El repositorio llega por el constructor: la clase no sabe cuál es.
import { Reserva } from "./reserva.js";
export class ReservarEspacio {
constructor(repo) { this.repo = repo; }
ejecutar(clubId, espacioId, inicio, fin) {
const reserva = new Reserva(clubId, espacioId, inicio, fin);
if (this.repo.hayConflicto(espacioId, inicio, fin)) {
throw new Error("Espacio ocupado en ese horario");
}
this.repo.guardar(reserva);
return reserva;
}
}
En módulos ES, los imports de archivos propios llevan siempre la extensión: "./reserva.js", no "./reserva". Olvidarla produce el error ERR_MODULE_NOT_FOUND.
4. Probar las reglas sin base de datos
Todavía no existe ninguna base de datos y ya puedes probar la regla de solapamiento. Para eso hace falta un adaptador de pruebas: un repositorio que guarda en un arreglo.
4.1 El repositorio en memoria. Va en test/ porque solo sirve para probar; el programa real nunca lo usará.
import { RepositorioDeReservas } from "../src/dominio/puertos.js";
export class RepoEnMemoria extends RepositorioDeReservas {
constructor() { super(); this.reservas = []; }
hayConflicto(espacioId, inicio, fin) {
return this.reservas.some(
(r) => r.espacioId === espacioId && r.inicio < fin && r.fin > inicio);
}
guardar(reserva) { this.reservas.push(reserva); }
}
4.2 Las pruebas del caso de uso. Usan el ejecutor que ya trae Node (node:test), sin instalar nada.
import { test } from "node:test";
import assert from "node:assert";
import { ReservarEspacio } from "../src/dominio/reservarEspacio.js";
import { RepoEnMemoria } from "./repoEnMemoria.js";
const hora = (h) => new Date(`2026-10-10T${h}:00`);
test("reserva un espacio libre", () => {
const caso = new ReservarEspacio(new RepoEnMemoria());
const reserva = caso.ejecutar(1, 7, hora("14"), hora("16"));
assert.strictEqual(reserva.espacioId, 7);
});
test("no permite reservar un horario que se solapa", () => {
const caso = new ReservarEspacio(new RepoEnMemoria());
caso.ejecutar(1, 7, hora("14"), hora("16"));
assert.throws(
() => caso.ejecutar(2, 7, hora("15"), hora("17")),
/Espacio ocupado/);
});
test("permite el mismo horario en otro espacio", () => {
const caso = new ReservarEspacio(new RepoEnMemoria());
caso.ejecutar(1, 7, hora("14"), hora("16"));
assert.doesNotThrow(() => caso.ejecutar(2, 8, hora("14"), hora("16")));
});
4.3 Ejecuta las pruebas.
npm test
✔ reserva un espacio libre
✔ no permite reservar un horario que se solapa
✔ permite el mismo horario en otro espacio
ℹ tests 3
ℹ pass 3
ℹ fail 0
Los tres pasan en milisegundos, sin base de datos, sin red y sin librerías. Esa es la ganancia de que el dominio dependa de un puerto y no de SQLite.
5. Un test que vigila la regla de dependencia
Una regla de arquitectura que solo está escrita en un documento se rompe sola con el tiempo. Mejor convertirla en una prueba: este test lee los archivos del dominio y falla si alguno importa algo de afuera.
import { test } from "node:test";
import assert from "node:assert";
import { readdirSync, readFileSync } from "node:fs";
test("el dominio no importa nada de afuera", () => {
const carpeta = "src/dominio";
for (const archivo of readdirSync(carpeta)) {
const codigo = readFileSync(`${carpeta}/${archivo}`, "utf8");
const imports = [...codigo.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]);
for (const origen of imports) {
assert.ok(origen.startsWith("./"),
`${archivo} importa "${origen}", pero el dominio solo puede importar dentro del dominio`);
}
}
});
npm test
Ahora ejecútalo con una violación. Agrega esta línea al principio de src/dominio/reserva.js, corre npm test y observa cómo falla:
import Database from "better-sqlite3";
✖ el dominio no importa nada de afuera
AssertionError: reserva.js importa "better-sqlite3", pero el dominio
solo puede importar dentro del dominio
Borra la línea y vuelve a correr npm test: deben pasar las cuatro pruebas.
6. El adaptador de SQLite y el punto de entrada
Recién ahora aparece una base de datos real, y lo hace en la capa de afuera, sin tocar una sola línea del dominio.
6.1 Instala la librería. Es la única dependencia externa del proyecto.
npm install better-sqlite3
Esto agrega la carpeta node_modules y una sección dependencies en package.json. Crea también un archivo .gitignore en la raíz para no subir a Git lo que no corresponde:
node_modules/
*.db
6.2 El adaptador. src/infraestructura/repoSqlite.js importa el puerto del dominio: la flecha apunta hacia el centro.
import { RepositorioDeReservas } from "../dominio/puertos.js";
export class RepoSqlite extends RepositorioDeReservas {
constructor(db) { super(); this.db = db; }
hayConflicto(espacioId, inicio, fin) {
const fila = this.db
.prepare("SELECT 1 FROM reserva WHERE espacio_id = ? AND inicio < ? AND fin > ?")
.get(espacioId, fin.toISOString(), inicio.toISOString());
return fila !== undefined;
}
guardar(r) {
this.db
.prepare("INSERT INTO reserva VALUES (?, ?, ?, ?)")
.run(r.clubId, r.espacioId, r.inicio.toISOString(), r.fin.toISOString());
}
}
6.3 El punto de entrada. src/main.js es el único archivo que conoce las dos capas: crea la base, elige el adaptador y se lo entrega al caso de uso.
import Database from "better-sqlite3";
import { ReservarEspacio } from "./dominio/reservarEspacio.js";
import { RepoSqlite } from "./infraestructura/repoSqlite.js";
const db = new Database("eventouab.db");
db.exec(`CREATE TABLE IF NOT EXISTS reserva (
club_id INTEGER, espacio_id INTEGER, inicio TEXT, fin TEXT)`);
const caso = new ReservarEspacio(new RepoSqlite(db));
const hora = (h) => new Date(`2026-10-10T${h}:00`);
caso.ejecutar(1, 7, hora("14"), hora("16"));
console.log("Reserva guardada.");
try {
caso.ejecutar(2, 7, hora("15"), hora("17"));
} catch (e) {
console.log("Rechazada:", e.message);
}
6.4 Ejecuta el programa.
npm start
Reserva guardada.
Rechazada: Espacio ocupado en ese horario
Se crea el archivo eventouab.db en la carpeta. Si ejecutas npm start otra vez sin borrarlo, el programa termina con un error «Espacio ocupado en ese horario»: es correcto, porque la primera reserva ya quedó guardada y el horario está ocupado. Los datos persisten. Para empezar de cero, borra el archivo eventouab.db.
Las mismas reglas corrieron con un arreglo en memoria (en las pruebas) y con SQLite (en el programa), sin cambiar ninguna línea de src/dominio. Si mañana Bienestar pide PostgreSQL, escribes repoPostgres.js y cambias una línea de main.js.
Errores frecuentes y cómo resolverlos
| Lo que ves | Causa probable | Solución |
|---|---|---|
Cannot use import statement outside a module | Falta "type": "module" en package.json. | Agrégalo y vuelve a ejecutar. |
ERR_MODULE_NOT_FOUND con una ruta propia | Un import sin extensión o con la ruta mal escrita. | Escribe "./reserva.js" y revisa mayúsculas y carpetas. |
Cannot find package 'better-sqlite3' | No ejecutaste la instalación, o estás en otra carpeta. | Ve a la carpeta del proyecto y corre npm install better-sqlite3. |
no implementado | Tu adaptador no define uno de los métodos del puerto, o lo escribiste con otro nombre. | Revisa que se llamen exactamente hayConflicto y guardar. |
npm test no encuentra pruebas | Los archivos de prueba no terminan en .test.js o no están en test/. | Renómbralos según la estructura del paso 2. |
Actividades de repaso
Cada actividad se corrige al instante; tu progreso se guarda automáticamente en este navegador.
¿En qué carpeta va cada archivo?
reservarEspacio.js, el caso de uso.
repoSqlite.js, que escribe el INSERT.
repoEnMemoria.js, que guarda en un arreglo.
puertos.js, con RepositorioDeReservas.
arquitectura.test.js, que vigila los imports.
reserva.js, con la regla del fin posterior al inicio.
Al ejecutar npm test aparece «Cannot use import statement outside a module». ¿Qué falta?
¿Por qué repoEnMemoria.js está en test/ y no en src/?
🎯 Reto: agrega una regla nueva, primero con la prueba
EventoUAB ahora pide que ninguna reserva dure más de cuatro horas. Sin tocar la infraestructura, escribe: 1) la prueba que falla (una reserva de cinco horas debe lanzar un error), 2) en qué archivo del dominio pondrás la regla y por qué ahí, 3) el cambio mínimo de código que hace pasar la prueba, 4) cómo confirmas con npm test que las pruebas anteriores siguen pasando y que el test de arquitectura no se rompió.
Una regla nueva del dominio, escrita primero como prueba.
- La prueba se escribe antes del cambio y se comprueba que falla por la razón correcta.
- La regla vive en
reserva.js(la entidad que se defiende a sí misma), no en el adaptador ni enmain.js. - El cambio no agrega ningún import nuevo al dominio.
- No se modificó ningún archivo de
src/infraestructura: la regla no depende de la base. - Al final corren las cuatro pruebas anteriores más la nueva, todas en verde.