Fundamentos de la Arquitectura de Software
1. Arquitectura de Software: Fundamentos Prácticos para Sistemas Distribuidos
1. Introducción a la Arquitectura de Software
La arquitectura de software define cómo se organizan los componentes de un sistema, sus responsabilidades y las relaciones entre ellos. Es el esqueleto que determina la salud, mantenibilidad y escalabilidad de tu aplicación a largo plazo.
1.1 Definición Práctica
Ejemplo conceptual: Una aplicación web básica donde:
- El navegador (Frontend) se encarga de la presentación.
- El servidor (Backend) maneja la lógica de negocio.
- La base de datos almacena la información.
Esta separación ya constituye una decisión arquitectónica fundamental.
2. Arquitectura vs. Código Caótico: Un Ejemplo Visual
Vamos a implementar dos versiones de la misma aplicación para entender la diferencia práctica.
2.1 Ejemplo Problemático: Arquitectura Monolítica Acoplada
Este enfoque mezcla todas las responsabilidades en un solo lugar, creando lo que se conoce como "código espagueti".
Estructura del proyecto:
proyecto-malo/
├── server-caotico.js
├── app.db
└── package.json
Código completo (server-caotico.js):
// Importamos Express, el framework HTTP.
// Nos permite definir rutas, manejar peticiones y respuestas.
import express from 'express';
// Importamos sqlite3, el driver de bajo nivel para SQLite.
import sqlite3 from 'sqlite3';
// Importamos open desde 'sqlite', que nos da una API moderna basada en promesas.
// Esto evita trabajar con callbacks directamente.
import { open } from 'sqlite';
// Creamos la aplicación Express.
const app = express();
// Puerto donde escuchará el servidor.
const PORT = 3000;
// Variable global para la conexión a la base de datos.
// Esto ya es una primera señal de acoplamiento: toda la app depende de este estado global.
let db;
// Función asíncrona para inicializar la base de datos.
async function initDB() {
// Abrimos (o creamos si no existe) el archivo SQLite.
db = await open({
filename: 'app.db',
driver: sqlite3.Database
});
// Creamos la tabla usuarios si no existe.
// Esta lógica de infraestructura está directamente en el servidor HTTP.
await db.exec(`
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
fecha_registro DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Consultamos cuántos usuarios hay actualmente.
const count = await db.get("SELECT COUNT(*) as total FROM usuarios");
// Si la tabla está vacía, insertamos datos de ejemplo.
// Esto mezcla inicialización, datos de demo y lógica de negocio.
if (count.total === 0) {
await db.exec(`
INSERT INTO usuarios (nombre, email) VALUES
('Ana García', 'ana@ejemplo.com'),
('Luis Martínez', 'luis@ejemplo.com'),
('Carlos Rodríguez', 'carlos@ejemplo.com')
`);
}
}
// Ruta GET /usuarios.
// Esta función hace demasiadas cosas a la vez.
app.get('/usuarios', async (req, res) => {
try {
// 1. Acceso directo a la base de datos desde la ruta.
// No existe una capa de repositorio ni de servicios.
const usuarios = await db.all("SELECT * FROM usuarios");
// 2. Construcción manual del HTML.
// El backend está actuando como motor de plantillas.
let html = `
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Usuarios - Enfoque Monolítico</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.badge-approach { background-color: #dc3545; }
.monolith-warning { border-left: 5px solid #dc3545; padding-left: 15px; }
</style>
</head>
<body class="container mt-5">
<div class="alert alert-danger">
<h4 class="alert-heading">Arquitectura Problemática</h4>
<p>Esta página muestra los problemas de una arquitectura acoplada donde:</p>
<ul>
<li>La lógica de negocio, acceso a datos y presentación están mezcladas</li>
<li>Cualquier cambio requiere modificar esta función completa</li>
<li>Difícil de testear y mantener</li>
</ul>
</div>
<div class="card">
<div class="card-header bg-danger text-white">
<h2 class="mb-0">Lista de Usuarios <span class="badge bg-light text-danger">Acoplado</span></h2>
</div>
<div class="card-body">
`;
// 3. Lógica de presentación mezclada con JavaScript del servidor.
// Se formatean fechas y se generan fragmentos HTML aquí mismo.
usuarios.forEach(usuario => {
const fecha = new Date(usuario.fecha_registro).toLocaleDateString('es-ES');
html += `
<div class="row mb-3 monolith-warning">
<div class="col-md-4">
<strong>${usuario.nombre}</strong>
</div>
<div class="col-md-4">
${usuario.email}
</div>
<div class="col-md-4">
<small class="text-muted">Registrado: ${fecha}</small>
</div>
</div>`;
});
// Cerramos el HTML e incluimos lógica de resumen.
html += `
</div>
<div class="card-footer text-muted">
Total: ${usuarios.length} usuarios
</div>
</div>
<div class="mt-4">
<h5>Problemas de este enfoque:</h5>
<ol>
<li>Acoplamiento alto entre datos y presentación</li>
<li>Dificultad para reutilizar la lógica</li>
<li>Imposible probar la lógica sin levantar el servidor</li>
<li>Escalabilidad limitada</li>
</ol>
</div>
</body>
</html>`;
// Enviamos directamente HTML como respuesta.
res.send(html);
} catch (error) {
// Manejo de errores muy básico.
// No hay distinción entre tipos de error ni logging estructurado.
console.error("Error:", error);
res.status(500).send("Error interno del servidor");
}
});
// Ruta POST /usuarios.
// De nuevo, validación, lógica de negocio y respuesta están mezcladas.
app.post('/usuarios', express.urlencoded({ extended: true }), async (req, res) => {
const { nombre, email } = req.body;
// Validación mínima directamente en la ruta.
if (!nombre || !email) {
return res.send(`
<div class="alert alert-warning">
Faltan datos. <a href="/usuarios">Volver</a>
</div>
`);
}
// Inserción directa en base de datos.
await db.run(
"INSERT INTO usuarios (nombre, email) VALUES (?, ?)",
[nombre, email]
);
// Redirección directa tras la operación.
res.redirect('/usuarios');
});
// Función de arranque del servidor.
async function startServer() {
// Inicializamos la base de datos antes de escuchar peticiones.
await initDB();
app.listen(PORT, () => {
console.log(`Servidor ejecutándose en http://localhost:${PORT}/usuarios`);
console.log(`Este ejemplo muestra una arquitectura acoplada con problemas claros`);
});
}
// Arrancamos la aplicación.
startServer();
Configuración (package.json):
{
"name": "arquitectura-mala-ejemplo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server-caotico.js",
"reset-db": "rm -f app.db && node server-caotico.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"sqlite": "^5.1.1"
}
}
Instrucciones de ejecución:
# 1. Crear directorio y entrar
mkdir arquitectura-ejemplos && cd arquitectura-ejemplos
# 2. Inicializar proyecto e instalar dependencias
npm init -y
npm install express sqlite3 sqlite
# 3. Crear el archivo server-caotico.js con el código anterior
# 4. Ejecutar el servidor problemático
node server-caotico.js
# 5. Visitar en navegador: <http://localhost:3000/usuarios>
Resultado visible:
Al ejecutar y visitar http://localhost:3000/usuarios, verás una interfaz con Bootstrap que muestra:
- Un alerta roja explicando los problemas arquitectónicos
- Lista de usuarios con marcado visual de "arquitectura problemática"
- Explicación detallada de los inconvenientes de este enfoque
2.2 Ejemplo Correcto: Arquitectura en Capas (Layered Architecture)
Ahora implementemos la misma funcionalidad con una arquitectura bien estructurada.
Estructura del proyecto:
proyecto-bueno/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── controllers/
│ │ └── usuarios.controller.js
│ ├── models/
│ │ └── usuario.model.js
│ ├── routes/
│ │ └── usuarios.routes.js
│ ├── services/
│ │ └── usuarios.service.js
│ ├── views/
│ │ ├── layout.html
│ │ └── usuarios.html
│ └── app.js
├── public/
│ └── css/
│ └── styles.css
├── package.json
└── app.db
1. Configuración de base de datos (src/config/database.js):
// Importamos sqlite3, que actúa como driver de bajo nivel para SQLite.
// Este driver es el que realmente se comunica con el archivo .db.
import sqlite3 from 'sqlite3';
// Importamos la función open desde el paquete sqlite.
// Esta capa envuelve sqlite3 y nos permite trabajar con promesas
// en lugar de callbacks, lo que simplifica mucho el código asíncrono.
import { open } from 'sqlite';
// Esta función se encarga exclusivamente de inicializar la base de datos.
// No sabe nada de Express, rutas HTTP ni HTML.
// Su única responsabilidad es preparar y devolver una conexión a SQLite.
export async function initDatabase() {
// Abrimos (o creamos si no existe) el archivo de base de datos.
// La función open devuelve un objeto db que usaremos para ejecutar consultas.
const db = await open({
filename: 'app.db',
driver: sqlite3.Database
});
// Ejecutamos una sentencia SQL para crear la tabla usuarios si no existe.
// Esta lógica pertenece a la capa de infraestructura de datos.
// Aquí se define la estructura de la base de datos, no la lógica de negocio.
await db.exec(`
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
email TEXT NOT NULL,
fecha_registro DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Devolvemos la conexión a la base de datos ya inicializada.
// Esto permite que otras capas (repositorios, servicios)
// usen la base de datos sin saber cómo se creó.
return db;
}
2. Modelo de datos (src/models/usuario.model.js):
// Definimos la clase UsuarioModel.
// Esta clase representa la capa de acceso a datos (modelo o repositorio).
// Su responsabilidad es única: comunicarse con la base de datos.
export class UsuarioModel {
// El constructor recibe una instancia de la base de datos.
// Esto permite inyectar la dependencia desde fuera,
// en lugar de crear la conexión aquí dentro.
constructor(db) {
this.db = db;
}
// Devuelve todos los usuarios ordenados por fecha de registro.
// Este método encapsula completamente la consulta SQL.
// Las capas superiores no necesitan conocer la estructura de la tabla.
async findAll() {
return await this.db.all(
"SELECT * FROM usuarios ORDER BY fecha_registro DESC"
);
}
// Busca un usuario por su id.
// Si no existe, SQLite devolverá undefined.
// La decisión de qué hacer en ese caso pertenece a capas superiores.
async findById(id) {
return await this.db.get(
"SELECT * FROM usuarios WHERE id = ?",
id
);
}
// Crea un nuevo usuario en la base de datos.
// Este método solo se preocupa de persistir datos,
// no valida ni aplica reglas de negocio.
async create(nombre, email) {
const result = await this.db.run(
"INSERT INTO usuarios (nombre, email) VALUES (?, ?)",
[nombre, email]
);
// Devolvemos el id generado automáticamente por SQLite.
// Esto es útil para confirmar la creación o encadenar operaciones.
return result.lastID;
}
// Actualiza los datos de un usuario existente.
// Si el id no existe, la consulta no afectará a ninguna fila.
// De nuevo, la lógica de comprobación se delega a otra capa.
async update(id, nombre, email) {
await this.db.run(
"UPDATE usuarios SET nombre = ?, email = ? WHERE id = ?",
[nombre, email, id]
);
}
// Elimina un usuario por id.
// Este método no devuelve nada, solo ejecuta la operación.
async delete(id) {
await this.db.run(
"DELETE FROM usuarios WHERE id = ?",
id
);
}
}
3. Servicio de negocio (src/services/usuarios.service.js):
// Importamos el modelo de Usuario.
// El servicio no accede directamente a la base de datos,
// sino que delega el acceso en el modelo.
import { UsuarioModel } from '../models/usuario.model.js';
// Definimos la clase UsuarioService.
// Esta clase representa la capa de servicios o lógica de negocio.
// Su responsabilidad es aplicar reglas, validaciones y transformaciones.
export class UsuarioService {
// El constructor recibe una instancia del modelo.
// Esto permite desacoplar el servicio del sistema de persistencia.
constructor(model) {
this.model = model;
}
// Obtiene todos los usuarios.
// En este método podrían añadirse filtros, permisos,
// paginación o reglas específicas del dominio.
async obtenerTodosLosUsuarios() {
// De momento, delega directamente en el modelo.
// El controlador no sabe cómo se obtienen los datos.
return await this.model.findAll();
}
// Crea un nuevo usuario aplicando reglas de negocio.
// El controlador solo pasa datos, no toma decisiones.
async crearUsuario(datosUsuario) {
// Validación de reglas de negocio básicas.
// Estas comprobaciones NO deben estar en el controlador
// ni en el modelo.
if (!datosUsuario.nombre || !datosUsuario.email) {
throw new Error('Nombre y email son obligatorios');
}
// Validación del formato del email.
// Centralizar esta lógica evita duplicación en otras rutas.
if (!this.validarEmail(datosUsuario.email)) {
throw new Error('Email no válido');
}
// Transformación de datos según reglas del dominio.
// Aquí se decide cómo debe almacenarse el nombre.
const nombreCapitalizado =
this.capitalizarNombre(datosUsuario.nombre);
// Delegamos la persistencia en el modelo.
// El servicio no ejecuta SQL.
return await this.model.create(
nombreCapitalizado,
datosUsuario.email
);
}
// Método auxiliar para validar emails.
// Está encapsulado dentro del servicio porque
// forma parte de la lógica del dominio.
validarEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Método auxiliar para normalizar nombres.
// Convierte cada palabra a formato Capitalizado.
capitalizarNombre(nombre) {
return nombre
.split(' ')
.map(palabra =>
palabra.charAt(0).toUpperCase() +
palabra.slice(1).toLowerCase()
)
.join(' ');
}
}
4. Controlador (src/controllers/usuarios.controller.js):
// Definimos la clase UsuarioController.
// Esta clase representa la capa de controladores.
// Su responsabilidad es gestionar la comunicación HTTP:
// recibir la petición, llamar al servicio y devolver una respuesta.
export class UsuarioController {
// El controlador recibe una instancia del servicio.
// No conoce modelos ni base de datos.
// Esto reduce el acoplamiento y facilita el testeo.
constructor(service) {
this.service = service;
}
// Controlador para listar usuarios.
// Se encarga de coordinar la petición HTTP GET /usuarios.
async listarUsuarios(req, res) {
try {
// Llamamos al servicio para obtener los usuarios.
// El controlador no aplica lógica de negocio.
const usuarios =
await this.service.obtenerTodosLosUsuarios();
// Renderizamos la vista correspondiente.
// El controlador decide qué vista usar y qué datos pasarle.
res.render('usuarios', {
usuarios,
title: 'Usuarios - Arquitectura en Capas',
message: 'Esta implementación muestra una arquitectura bien estructurada'
});
} catch (error) {
// Manejo de errores a nivel HTTP.
// El controlador traduce errores internos a respuestas entendibles.
console.error('Error en controlador:', error);
res.status(500).render('error', {
error: 'Error al obtener usuarios',
details: error.message
});
}
}
// Controlador para crear un nuevo usuario.
// Gestiona la petición POST /usuarios.
async crearUsuario(req, res) {
try {
// Pasamos directamente los datos recibidos al servicio.
// El controlador no valida ni transforma datos.
await this.service.crearUsuario(req.body);
// Tras una creación correcta, redirigimos.
// Esta es una decisión propia del flujo HTTP.
res.redirect('/usuarios');
} catch (error) {
// Si el servicio lanza un error de negocio,
// el controlador lo convierte en una respuesta HTTP adecuada.
res.status(400).render('error', {
error: 'Error al crear usuario',
details: error.message
});
}
}
}
5. Rutas (src/routes/usuarios.routes.js):
// Importamos Express para poder crear un router independiente.
// Un router permite agrupar rutas relacionadas en un módulo separado.
import express from 'express';
// Esta función crea y configura el router de usuarios.
// Recibe el controlador como dependencia, en lugar de importarlo directamente.
// Esto mantiene el router desacoplado de implementaciones concretas.
export function crearRouterUsuarios(controller) {
// Creamos una nueva instancia del router de Express.
// Este router se montará posteriormente en la aplicación principal.
const router = express.Router();
// Ruta GET /usuarios
// El router se limita a mapear una URL y un método HTTP
// con un método concreto del controlador.
router.get(
'/',
// Usamos bind para asegurar que "this" dentro del controlador
// apunte correctamente a la instancia del controlador.
controller.listarUsuarios.bind(controller)
);
// Ruta POST /usuarios
// De nuevo, el router no ejecuta lógica alguna,
// solo delega la petición al controlador correspondiente.
router.post(
'/',
controller.crearUsuario.bind(controller)
);
// Devolvemos el router ya configurado.
// La aplicación principal decidirá en qué path montarlo.
return router;
}
6. Vistas (src/views/usuarios.html):
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<link href="<https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css>" rel="stylesheet">
<style>
.badge-approach { background-color: #28a745; }
.arch-good { border-left: 5px solid #28a745; padding-left: 15px; margin-bottom: 15px; }
.benefit-list { list-style-type: none; padding-left: 0; }
.benefit-list li { padding: 5px 0; }
.benefit-list li:before { content: "✓ "; color: #28a745; font-weight: bold; }
</style>
</head>
<body class="container mt-5">
<div class="alert alert-success">
<h4 class="alert-heading">✅ Arquitectura en Capas Correcta</h4>
<p>{{message}}</p>
<hr>
<p class="mb-0">Cada capa tiene una responsabilidad única y clara:</p>
</div>
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-white bg-primary mb-3">
<div class="card-header">📁 Modelo</div>
<div class="card-body">
<p class="card-text">Acceso a datos. Solo conoce la base de datos.</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-success mb-3">
<div class="card-header">⚙️ Servicio</div>
<div class="card-body">
<p class="card-text">Lógica de negocio. Reglas y validaciones.</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-info mb-3">
<div class="card-header">🎮 Controlador</div>
<div class="card-body">
<p class="card-text">Orquesta el flujo entre vista y servicio.</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-warning mb-3">
<div class="card-header">🎨 Vista</div>
<div class="card-body">
<p class="card-text">Presentación. Solo muestra datos.</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-success text-white">
<h2 class="mb-0">Lista de Usuarios <span class="badge bg-light text-success">Desacoplado</span></h2>
</div>
<div class="card-body">
{{#each usuarios}}
<div class="row mb-3 arch-good">
<div class="col-md-4">
<strong>{{this.nombre}}</strong>
</div>
<div class="col-md-4">
{{this.email}}
</div>
<div class="col-md-4">
<small class="text-muted">Registrado: {{formatDate this.fecha_registro}}</small>
</div>
</div>
{{/each}}
</div>
<div class="card-footer">
<div class="row">
<div class="col-md-6">
<strong>Total: {{usuarios.length}} usuarios</strong>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal">
+ Agregar Usuario
</button>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h5>Beneficios de esta arquitectura:</h5>
<ul class="benefit-list">
<li><strong>Bajo acoplamiento</strong>: Cambiar la base de datos no afecta la vista</li>
<li><strong>Reusabilidad</strong>: El servicio puede usarse desde API o CLI</li>
<li><strong>Testeabilidad</strong>: Cada capa se prueba independientemente</li>
<li><strong>Escalabilidad</strong>: Capas pueden distribuirse en diferentes servidores</li>
<li><strong>Mantenibilidad</strong>: Cambios localizados en una sola capa</li>
</ul>
</div>
<!-- Modal para agregar usuario -->
<div class="modal fade" id="addUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Agregar Nuevo Usuario</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/usuarios" method="POST">
<div class="modal-body">
<div class="mb-3">
<label for="nombre" class="form-label">Nombre completo</label>
<input type="text" class="form-control" id="nombre" name="nombre" required>
<div class="form-text">Ej: María González</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required>
<div class="form-text">Ej: maria@ejemplo.com</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar Usuario</button>
</div>
</form>
</div>
</div>
</div>
<script src="<https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js>"></script>
<script>
// Helper para formatear fechas
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
// Aplicar formato a todas las fechas
document.querySelectorAll('.text-muted small').forEach(el => {
const text = el.textContent;
const dateMatch = text.match(/Registrado: (.+)/);
if (dateMatch) {
el.textContent = `Registrado: ${formatDate(dateMatch[1])}`;
}
});
</script>
</body>
</html>
7. Aplicación principal (src/app.js):
// Servidor Express con arquitectura en capas (Model -> Service -> Controller -> Router).
import express from 'express';
import path from 'path';
// En ESM no existe __dirname / __filename por defecto.
// fileURLToPath + import.meta.url permiten recrearlos.
import { fileURLToPath } from 'url';
// Capa de infraestructura: inicializa SQLite y devuelve una conexión db.
import { initDatabase } from './config/database.js';
// Capa de datos: consultas SQL encapsuladas.
import { UsuarioModel } from './models/usuario.model.js';
// Capa de negocio: validaciones y reglas del dominio.
import { UsuarioService } from './services/usuarios.service.js';
// Capa HTTP: traduce petición/respuesta a llamadas al servicio.
import { UsuarioController } from './controllers/usuarios.controller.js';
// Capa de rutas: conecta endpoints con métodos del controlador.
import { crearRouterUsuarios } from './routes/usuarios.routes.js';
// Recreamos __filename y __dirname para poder resolver rutas del sistema de archivos.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Función principal de arranque.
// Aquí se "cablean" (se ensamblan) todas las piezas de la app.
async function bootstrap() {
const app = express();
const PORT = 3001;
// Middleware para parsear JSON (APIs).
app.use(express.json());
// Middleware para parsear formularios HTML (application/x-www-form-urlencoded).
// extended:true permite objetos más complejos.
app.use(express.urlencoded({ extended: true }));
// Servir archivos estáticos (CSS, imágenes, JS del navegador).
// Ojo: el path apunta a ../public respecto a /src (si este archivo está en /src).
app.use(express.static(path.join(__dirname, '../public')));
// Configuramos la carpeta de vistas (plantillas HTML).
app.set('views', path.join(__dirname, 'views'));
// Indicamos extensión por defecto de plantillas.
// Aquí se usa "html" como engine custom (no EJS, no Pug).
app.set('view engine', 'html');
// Registramos un motor de plantillas manual.
// Importante: este engine mezcla ESM (imports) con require (CommonJS),
// lo cual puede dar problemas si tu proyecto es 100% "type":"module".
app.engine('html', (filePath, options, callback) => {
// Lectura de ficheros de plantilla.
const fs = require('fs');
// Handlebars compila plantillas y sustituye variables del objeto options.
const handlebars = require('handlebars');
// Helper reutilizable para formatear fechas en las vistas.
// Se usa en la plantilla como: {{formatDate fecha_registro}}
handlebars.registerHelper('formatDate', function(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
// Leemos el HTML, lo compilamos con Handlebars y devolvemos el resultado.
fs.readFile(filePath, 'utf-8', (err, content) => {
if (err) return callback(err);
const template = handlebars.compile(content);
const html = template(options);
return callback(null, html);
});
});
// Ensamblaje de dependencias (Dependency Injection manual).
// La app crea las instancias en un único punto (bootstrap).
const db = await initDatabase();
const model = new UsuarioModel(db);
const service = new UsuarioService(model);
const controller = new UsuarioController(service);
// Montamos el router de usuarios en /usuarios.
// GET /usuarios y POST /usuarios quedan definidos en usuarios.routes.js
const usuariosRouter = crearRouterUsuarios(controller);
app.use('/usuarios', usuariosRouter);
// Ruta raíz: redirige a la pantalla principal.
app.get('/', (req, res) => {
res.redirect('/usuarios');
});
// Ruta utilitaria para insertar datos de ejemplo.
// Está bien para demo, pero en producción se suele evitar exponer esto.
app.get('/poblar-ejemplo', async (req, res) => {
try {
await db.exec(`
INSERT INTO usuarios (nombre, email) VALUES
('Ana García', 'ana@ejemplo.com'),
('Luis Martínez', 'luis@ejemplo.com'),
('Carlos Rodríguez', 'carlos@ejemplo.com'),
('María López', 'maria@ejemplo.com'),
('Pedro Sánchez', 'pedro@ejemplo.com')
`);
res.redirect('/usuarios');
} catch (error) {
// Aquí devolvemos texto plano con el error.
// En una app real podrías renderizar una vista de error.
res.status(500).send('Error al poblar datos: ' + error.message);
}
});
// Arranque del servidor HTTP.
app.listen(PORT, () => {
console.log(`✅ Servidor bien estructurado ejecutándose en http://localhost:${PORT}`);
console.log(`📊 Panel de usuarios: http://localhost:${PORT}/usuarios`);
console.log(`📝 Para poblar datos de ejemplo: http://localhost:${PORT}/poblar-ejemplo`);
console.log('\n🎯 Beneficios de esta arquitectura:');
console.log(' 1. Separación clara de responsabilidades');
console.log(' 2. Código fácil de mantener y extender');
console.log(' 3. Capas independientes y testables');
console.log(' 4. Preparado para evolucionar a microservicios');
});
}
// Ejecutamos el arranque.
// Si algo falla antes (DB, engine, etc.) la promesa rechazará.
bootstrap();
Configuración completa (package.json):
{
"name": "arquitectura-buena-ejemplo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"reset-db": "rm -f app.db && node src/app.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"sqlite": "^5.1.1",
"handlebars": "^4.7.7"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
Instrucciones de ejecución completas:
# 1. Crear estructura de directorios
mkdir -p proyecto-bueno/src/{config,controllers,models,routes,services,views}
mkdir -p proyecto-bueno/public/css
# 2. Inicializar proyecto
cd proyecto-bueno
npm init -y
# 3. Editar package.json para usar ES modules y agregar scripts
# (Copiar el contenido de package.json mostrado arriba)
# 4. Instalar dependencias
npm install express sqlite3 sqlite handlebars
npm install --save-dev nodemon
# 5. Crear todos los archivos en sus respectivas carpetas
# (Copiar cada archivo en su ubicación correspondiente)
# 6. Ejecutar el servidor
npm start
# 7. Visitar en navegador: <http://localhost:3001/usuarios>
# 8. Para poblar datos: <http://localhost:3001/poblar-ejemplo>
3. Comparativa de Resultados
Ejecutando ambos ejemplos:
# Terminal 1: Ejecutar ejemplo problemático
cd arquitectura-ejemplos
node server-caotico.js
# Visitar: <http://localhost:3000/usuarios>
# Terminal 2: Ejecutar ejemplo bien estructurado
cd proyecto-bueno
npm start
# Visitar: <http://localhost:3001/usuarios>
Diferencias visibles:
| Aspecto | Ejemplo Problemático | Ejemplo Bien Estructurado |
|---|---|---|
| Interfaz | Alertas rojas, énfasis en problemas | Alertas verdes, énfasis en beneficios |
| Organización | Todo en un archivo | Múltiples archivos organizados por capa |
| Código | 200+ líneas en un archivo | 40-60 líneas por archivo, responsabilidad única |
| Mantenibilidad | Cambios afectan todo | Cambios localizados en una capa |
| Testabilidad | Difícil de testear | Cada capa testeable independientemente |
4. Ejercicio Práctico: Evolución de Requisitos
Situación: El cliente solicita agregar un nuevo campo "teléfono" a los usuarios.
En el ejemplo problemático:
// Se debe modificar TODO en un solo archivo:
// 1. Modificar la tabla en initDB()
// 2. Actualizar la consulta SQL en la ruta /usuarios
// 3. Actualizar la generación de HTML
// 4. Actualizar el formulario POST
// ¡Riesgo alto de romper algo!
En el ejemplo bien estructurado:
// Solo modificar dos archivos específicos:
// 1. En el modelo (src/models/usuario.model.js):
async create(nombre, email, telefono) {
const result = await this.db.run(
"INSERT INTO usuarios (nombre, email, telefono) VALUES (?, ?, ?)",
[nombre, email, telefono]
);
return result.lastID;
}
// 2. En la vista (src/views/usuarios.html):
// Agregar campo en el formulario y mostrar en la lista
// ¡El servicio y controlador pueden no necesitar cambios!
Instrucciones para el ejercicio:
# 1. Implementa el cambio en ambos proyectos
# 2. Compara el tiempo y complejidad
# 3. Nota cómo en la arquitectura en capas:
# - No necesitas tocar la lógica de negocio
# - El controlador sigue funcionando igual
# - Puedes testear el cambio de forma aislada