Evolución de la Arquitectura de Software
2. Arquitectura Monolítica
2.1 ¿Qué es una Arquitectura Monolítica?
Una arquitectura monolítica es un patrón de diseño de software donde todos los componentes de una aplicación están integrados en un solo programa que se ejecuta como un proceso único. El término "monolítico" proviene del griego monolithos (una sola piedra), y describe precisamente esta característica: todo el sistema es una unidad indivisible.
Características Técnicas Fundamentales
Base de código única: Todo el código fuente de la aplicación reside en un solo repositorio. Los desarrolladores trabajan sobre la misma codebase, comparten dependencias y compilan todo juntos.
Proceso único en ejecución: Cuando la aplicación se despliega, se ejecuta como un solo proceso en el sistema operativo. Este proceso maneja todas las solicitudes, procesa toda la lógica de negocio y accede a todos los datos.
Base de datos compartida: Generalmente, un monolito utiliza una única base de datos (o un conjunto de bases de datos fuertemente acopladas) donde todas las tablas están interrelacionadas y accesibles desde cualquier parte de la aplicación.
Comunicación por llamadas a funciones: Los diferentes módulos o componentes del sistema se comunican entre sí mediante simples llamadas a funciones o métodos dentro del mismo espacio de memoria. No hay serialización/deserialización de datos ni comunicación de red entre componentes.
Flujo de una Solicitud en un Monolito
Cuando un usuario realiza una petición a una aplicación monolítica, el flujo típico es:
Cliente HTTP → Servidor Web Único → Routing → Controlador → Servicio → Modelo → Base de Datos Única
Cada paso ocurre dentro del mismo proceso, lo que significa:
- No hay latencia de red entre componentes
- No hay problemas de serialización
- Los errores producen stack traces completos
- El estado se puede compartir fácilmente en memoria
Ejemplo Visual Simplificado
┌─────────────────────────────────────────────┐
│ PROCESO ÚNICO DEL MONOLITO │
├───────────┬───────────┬─────────────────────┤
│ Web │ Lógica │ Acceso a │
│ Server │ Negocio │ Datos │
│ (Express)│ (Services)│ (Models) │
├───────────┴───────────┴─────────────────────┤
│ Memoria Compartida │
└─────────────────────────────────────────────┘
│
┌─────▼─────┐
│ Base de │
│ Datos │
│ Única │
└───────────┘
2.2 Ventajas de la Arquitectura Monolítica
Desarrollo Rápido y Simple
Complejidad reducida inicialmente: Para equipos pequeños o proyectos nuevos, no tener que configurar múltiples servicios, orquestadores de contenedores, o sistemas de descubrimiento de servicios reduce drásticamente la complejidad de inicio.
Debugging simplificado: Cuando ocurre un error, el stack trace muestra el camino completo desde el punto de entrada HTTP hasta la línea de código problemática. No hay que rastrear errores a través de logs distribuidos en múltiples servicios.
Testing integral más fácil: Las pruebas end-to-end se pueden ejecutar contra la aplicación completa sin necesidad de mocks complejos para servicios externos. Un solo comando puede ejecutar todas las pruebas.
Despliegue y Operaciones Simples
Un solo artefacto para desplegar: El proceso de CI/CD es sencillo: construir un ejecutable, contenedor o paquete, y desplegarlo. No hay que coordinar versiones entre múltiples servicios.
Transacciones de base de datos ACID: Con una base de datos única, se garantiza la consistencia inmediata de los datos. No hay problemas de consistencia eventual ni necesidad de patrones complejos como SAGA.
Monitoreo centralizado: Todas las métricas, logs y trazas provienen de un solo proceso, lo que simplifica el monitoreo y la observabilidad.
Eficiencia de Rendimiento
Comunicación intra-proceso: Los componentes se comunican mediante llamadas a funciones en memoria, eliminando la latencia de red y la sobrecarga de serialización.
Cache compartida: El cache en memoria puede ser compartido por todos los componentes, mejorando el rendimiento para datos frecuentemente accedidos.
Optimización global: El compilador/transpilador puede optimizar todo el código como una unidad, potencialmente mejorando el rendimiento mediante optimizaciones globales.
Ideal para Ciertos Escenarios
MVP y prototipos: Cuando el objetivo principal es validar una idea de negocio rápidamente, la velocidad de desarrollo de un monolito es invaluable.
Equipos pequeños: Equipos de menos de 10 personas pueden coordinarse eficientemente en una única codebase sin generar conflictos excesivos.
Aplicaciones con dominio simple: Sistemas CRUD tradicionales o aplicaciones con lógica de negocio poco compleja no justifican la sobrecarga de una arquitectura distribuida.
2.3 Limitaciones de la Arquitectura Monolítica
Acoplamiento Excesivo
Dependencias circulares: A medida que el código crece, es común que surjan dependencias circulares entre módulos, donde el módulo A depende de B, B depende de C, y C depende de A.
Efectos colaterales no deseados: Cambiar un módulo puede tener efectos impredecibles en otros módulos debido a las dependencias implícitas y al estado compartido.
Difícil asignación de responsabilidades: Cuando todo está en el mismo repositorio, es difícil establecer límites claros de responsabilidad entre equipos.
Problemas de Escalabilidad
Escalado "todo o nada": Si solo un módulo necesita más recursos (ej: procesamiento de imágenes), debes escalar TODO el monolito, desperdiciando recursos en módulos que no lo necesitan.
Recursos compartidos: Un módulo con fugas de memoria o alto consumo de CPU afecta a toda la aplicación.
Punto único de fallo: Un error crítico en cualquier parte del sistema puede derribar toda la aplicación.
Complejidad Creciente del Código
Tiempos de compilación largos: A medida que la codebase crece, los tiempos de compilación pueden volverse prohibitivamente largos.
Dificultad para navegar el código: En codebases muy grandes, encontrar código específico se vuelve cada vez más difícil.
Onboarding lento: Nuevos desarrolladores necesitan entender TODO el sistema antes de poder contribuir efectivamente.
Restricciones Tecnológicas
Stack tecnológico homogéneo: Todo el equipo debe usar las mismas tecnologías, versiones y frameworks.
Dificultad para adoptar nuevas tecnologías: Migrar a una nueva versión de un framework o adoptar un nuevo lenguaje requiere reescribir toda la aplicación.
Imposibilidad de usar la mejor herramienta para cada trabajo: No puedes usar Python para machine learning y Go para servicios de red simultáneamente.
Desafíos Operacionales
Despliegues arriesgados: Cada despliegue implica actualizar TODO el sistema, aumentando el riesgo y creando ventanas de mantenimiento largas.
Testing lento: Las suites de pruebas completas pueden tomar horas en ejecutarse.
Rollbacks complejos: Revertir un cambio malo requiere revertir TODO el despliegue, incluso si solo un pequeño módulo tenía problemas.
2.4 ¿Cuándo es el Monolito una Buena Opción?
El Principio KISS Aplicado a Arquitectura
"Keep It Simple, Stupid": Un monolito es la implementación más simple de un sistema. Empieza simple y solo agrega complejidad cuando tengas pruebas concretas de que la necesitas.
Criterios de Decisión Prácticos
1. Tamaño del equipo:
- Monolito recomendado: 2-10 desarrolladores.
- Considerar alternativas: 11-20 desarrolladores.
- Probablemente necesites otra cosa: 20+ desarrolladores
2. Complejidad del dominio:
- Monolito adecuado: Dominio simple o moderado, modelos de datos predecibles
- Considerar microservicios: Dominio complejo con bounded contexts claros
3. Requisitos de escalabilidad:
- Monolito suficiente: Escalabilidad vertical adecuada, patrones de carga predecibles
- Necesitas distribución: Escalabilidad horizontal crítica, cargas variables por componente
4. Time-to-market:
- Monolito óptimo: MVP, validación de producto, lanzamientos rápidos
- Puedes tomar más tiempo: Producto establecido, evoluciones incrementales
5. Recursos disponibles:
- Monolito económico: Equipos pequeños, presupuestos limitados, sin DevOps dedicado
- Inversión justificada: Equipos grandes, presupuesto para infraestructura distribuida
La Regla de Oro
"Empieza con un monolito hasta que tengas una razón clara y demostrable para cambiarlo" - Esta filosofía, popularizada por Martin Fowler, evita la complejidad prematura.
Patrones para Monolitos Saludables
Monolito modular: Aunque todo esté en un proceso, organiza el código en módulos con interfaces claras y dependencias controladas.
Separación de preocupaciones: Mantén una clara separación entre capas (presentación, lógica, datos) incluso dentro del monolito.
Preparación para la descomposición: Diseña el monolito pensando en que algún día podrías necesitar extraer servicios, pero no implementes la infraestructura distribuida hasta que sea necesario.
Ejemplo Práctico: Sistema de Gestión de Proyectos (Monolítico)
Vamos a construir un sistema completo de gestión de proyectos que ilustre los conceptos del monolito. Este ejemplo será SUFICIENTEMENTE COMPLETO para mostrar las características del monolito pero SUFICIENTEMENTE SIMPLE para entenderlo fácilmente.
Estructura del Proyecto
proyecto-monolito/
├── package.json
├── server.js # Punto de entrada del servidor
├── database.js # Configuración de la base de datos
├── public/ # Archivos estáticos (frontend)
│ ├── index.html
│ ├── styles.css
│ └── app.js
└── src/ # Código del backend
├── models/ # Modelos de datos
│ ├── Proyecto.js
│ ├── Tarea.js
│ └── Usuario.js
├── services/ # Lógica de negocio
│ ├── proyectoService.js
│ └── tareaService.js
└── routes/ # Rutas de la API
├── proyectos.js
└── tareas.js
# 1. Crear directorio del proyecto
mkdir sistema-proyectos-monolito
cd sistema-proyectos-monolito
# 2. Inicializar proyecto Node.js
npm init -y
# 3. Instalar dependencias
npm install express sqlite3 sqlite
# 4. Instalar dependencias de desarrollo
npm install --save-dev nodemon
# 5. Crear estructura de directorios
mkdir -p src/{models,services,routes} public
# 6. Crear todos los archivos listados anteriormente
# - server.js, database.js
# - src/models/Proyecto.js, src/models/Tarea.js
# - src/services/proyectoService.js, src/services/tareaService.js
# - src/routes/proyectos.js, src/routes/tareas.js
# - public/index.html, public/styles.css, public/app.js
Paso 1: Configuración Inicial
package.json:
{
"name": "sistema-proyectos-monolito",
"version": "1.0.0",
"type": "module",
"description": "Sistema monolítico de gestión de proyectos",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"sqlite": "^5.1.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
Paso 2: Base de Datos Única (SQLite)
database.js:
// Importamos sqlite3 como driver de bajo nivel para SQLite.
// Este módulo es el encargado de comunicarse con el archivo físico .db.
import sqlite3 from 'sqlite3';
// Importamos open desde el paquete sqlite.
// Esta capa envuelve sqlite3 y proporciona una API basada en promesas.
import { open } from 'sqlite';
// Singleton de conexión a la base de datos.
// En un monolito, toda la aplicación suele compartir UNA sola conexión.
// dbInstance se mantiene en memoria durante toda la vida del proceso.
let dbInstance = null;
// Función pública para obtener la conexión a la base de datos.
// Si la conexión no existe, se crea y se inicializa.
// Si ya existe, se reutiliza.
export async function getDB() {
// Comprobamos si la conexión ya fue creada previamente.
if (!dbInstance) {
// Abrimos (o creamos si no existe) el archivo proyectos.db.
// Esta conexión será compartida por todos los módulos del sistema.
dbInstance = await open({
filename: './proyectos.db',
driver: sqlite3.Database
});
// Inicializamos la estructura de la base de datos.
// Estas funciones se ejecutan SOLO una vez,
// cuando se crea la conexión por primera vez.
await crearTablas(dbInstance);
await insertarDatosIniciales(dbInstance);
}
// Devolvemos siempre la misma instancia.
return dbInstance;
}
// Función privada encargada de crear las tablas necesarias.
// Define el esquema completo del dominio proyectos/tareas.
async function crearTablas(db) {
// Ejecutamos múltiples sentencias SQL en un solo exec.
// Esto es habitual para inicialización de esquemas.
await db.exec(`
CREATE TABLE IF NOT EXISTS proyectos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
descripcion TEXT,
fecha_inicio DATE DEFAULT CURRENT_DATE,
fecha_fin DATE,
estado TEXT DEFAULT 'activo',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proyecto_id INTEGER NOT NULL,
titulo TEXT NOT NULL,
descripcion TEXT,
asignado_a TEXT,
estado TEXT DEFAULT 'pendiente',
prioridad INTEGER DEFAULT 2,
fecha_vencimiento DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (proyecto_id) REFERENCES proyectos(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
nombre TEXT NOT NULL,
rol TEXT DEFAULT 'cliente',
direccion TEXT,
telefono TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
// Índices para mejorar rendimiento en consultas frecuentes.
CREATE INDEX IF NOT EXISTS idx_tareas_proyecto ON tareas(proyecto_id);
CREATE INDEX IF NOT EXISTS idx_tareas_estado ON tareas(estado);
`);
}
// Función privada para insertar datos iniciales de ejemplo.
// Se usa normalmente en entornos de desarrollo o demos educativas.
async function insertarDatosIniciales(db) {
// Comprobamos si ya existen proyectos.
// Evita insertar datos duplicados en cada arranque.
const proyectoCount =
await db.get('SELECT COUNT(*) as count FROM proyectos');
if (proyectoCount.count === 0) {
// Insertamos proyectos y tareas de ejemplo.
// Esto ayuda a tener datos reales desde el primer arranque.
await db.exec(`
INSERT INTO proyectos (nombre, descripcion, estado) VALUES
('Sistema de Gestión Monolítico', 'Desarrollar un sistema de gestión de proyectos', 'activo'),
('Migración a Microservicios', 'Planificar migración del sistema actual', 'planificacion'),
('App Móvil Cliente', 'Desarrollo de app iOS y Android', 'activo');
INSERT INTO tareas (proyecto_id, titulo, descripcion, asignado_a, estado, prioridad) VALUES
(1, 'Diseñar arquitectura', 'Definir componentes y módulos', 'Ana García', 'completada', 1),
(1, 'Implementar backend', 'Desarrollo API REST', 'Carlos López', 'en_progreso', 1),
(1, 'Desarrollar frontend', 'Interfaz de usuario con Bootstrap', 'María Rodríguez', 'pendiente', 2),
(2, 'Analizar dependencias', 'Identificar módulos acoplados', 'Pedro Martínez', 'pendiente', 1),
(3, 'Diseñar UI/UX', 'Wireframes y prototipos', 'Laura Fernández', 'en_progreso', 2);
INSERT INTO usuarios (email, password, nombre, rol) VALUES
('admin@empresa.com', 'hashed_admin_password', 'Administrador Principal', 'admin'),
('juan@empresa.com', 'hashed_juan_password', 'Juan Pérez', 'cliente'),
('maria@empresa.com', 'hashed_maria_password', 'María García', 'cliente');
`);
console.log('Datos iniciales insertados');
}
}
// Función pública para cerrar la conexión a la base de datos.
// Es especialmente útil en:
// - Tests automatizados
// - Scripts de mantenimiento
// - Apagado controlado de la aplicación
export async function closeDB() {
if (dbInstance) {
await dbInstance.close();
dbInstance = null;
}
}
El archivo database.js es el punto central de acceso a la base de datos de toda la aplicación. Su función principal es asegurarse de que todo el sistema use una única conexión compartida a la base de datos, algo muy característico de una arquitectura monolítica.
En términos simples, database.js actúa como:
- El lugar donde se abre la base de datos
- El responsable de crear las tablas si no existen
- El encargado de insertar datos iniciales
- El mecanismo que entrega siempre la misma conexión a todos los modelos y servicios
Gracias a este archivo, ningún otro módulo necesita preocuparse de cómo se conecta SQLite, dónde está el archivo .db o si la base ya está inicializada.
Por qué es clave en un monolito
En un monolito, todos los módulos viven en el mismo proceso. database.js aprovecha esto usando un patrón muy simple: una sola instancia de conexión que se reutiliza durante toda la vida de la aplicación.
Esto tiene varias consecuencias importantes para el aprendizaje:
- Todos los modelos trabajan sobre el mismo estado compartido
- No hay conexiones duplicadas ni conflictos entre módulos
- Las transacciones y las claves foráneas funcionan de forma directa
- El comportamiento del sistema es fácil de razonar y depurar
Database.js deja muy claro que la base de datos es un recurso común del sistema, no algo aislado por servicio o por módulo.
Paso 3: Modelos de Datos
src/models/Proyecto.js:
// Importamos la función getDB desde el módulo de base de datos.
// getDB implementa un Singleton, por lo que siempre devuelve
// la MISMA conexión SQLite compartida por toda la aplicación.
import { getDB } from '../../database.js';
// Modelo de Proyecto.
// Esta clase pertenece a la capa de acceso a datos (modelo/repository).
// En un monolito, todos los métodos acceden al mismo estado compartido:
// una única base de datos y una única conexión.
export class ProyectoModel {
// Obtiene todos los proyectos junto con estadísticas agregadas.
// Combina datos de proyectos y tareas mediante JOIN.
async findAll() {
// Obtenemos la conexión compartida.
const db = await getDB();
// Consulta que:
// - Selecciona todos los campos del proyecto.
// - Cuenta el total de tareas asociadas.
// - Cuenta cuántas tareas están completadas.
// LEFT JOIN permite incluir proyectos sin tareas.
return await db.all(`
SELECT p.*,
COUNT(t.id) as total_tareas,
SUM(CASE WHEN t.estado = 'completada' THEN 1 ELSE 0 END) as tareas_completadas
FROM proyectos p
LEFT JOIN tareas t ON p.id = t.proyecto_id
GROUP BY p.id
ORDER BY p.created_at DESC
`);
}
// Busca un proyecto concreto por su id.
// Devuelve el proyecto o undefined si no existe.
async findById(id) {
const db = await getDB();
return await db.get(
'SELECT * FROM proyectos WHERE id = ?',
id
);
}
// Crea un nuevo proyecto en la base de datos.
// Recibe un objeto proyecto con los datos necesarios.
async create(proyecto) {
const db = await getDB();
// Insertamos el proyecto.
// Si no se indica estado, se usa 'activo' por defecto.
const result = await db.run(
`INSERT INTO proyectos (nombre, descripcion, fecha_inicio, fecha_fin, estado)
VALUES (?, ?, ?, ?, ?)`,
[
proyecto.nombre,
proyecto.descripcion,
proyecto.fecha_inicio,
proyecto.fecha_fin,
proyecto.estado || 'activo'
]
);
// Devolvemos el proyecto creado junto con el id generado.
return { id: result.lastID, ...proyecto };
}
// Actualiza un proyecto existente.
// No valida reglas de negocio, solo persiste cambios.
async update(id, proyecto) {
const db = await getDB();
await db.run(
`UPDATE proyectos
SET nombre = ?, descripcion = ?, fecha_inicio = ?,
fecha_fin = ?, estado = ?
WHERE id = ?`,
[
proyecto.nombre,
proyecto.descripcion,
proyecto.fecha_inicio,
proyecto.fecha_fin,
proyecto.estado,
id
]
);
// Se devuelve el objeto actualizado como confirmación.
return { id, ...proyecto };
}
// Elimina un proyecto por id.
async delete(id) {
const db = await getDB();
// Gracias a la clave foránea con ON DELETE CASCADE,
// todas las tareas asociadas al proyecto se eliminan automáticamente.
// Esta lógica queda delegada a la base de datos.
await db.run(
'DELETE FROM proyectos WHERE id = ?',
id
);
}
// Devuelve estadísticas globales del sistema de proyectos.
// Este tipo de consulta suele alimentar dashboards.
async getEstadisticas() {
const db = await getDB();
// Consulta agregada que calcula:
// - Total de proyectos.
// - Proyectos activos.
// - Proyectos completados.
// - Total de tareas (subconsulta).
return await db.get(`
SELECT
COUNT(*) as total_proyectos,
SUM(CASE WHEN estado = 'activo' THEN 1 ELSE 0 END) as proyectos_activos,
SUM(CASE WHEN estado = 'completado' THEN 1 ELSE 0 END) as proyectos_completados,
(SELECT COUNT(*) FROM tareas) as total_tareas
FROM proyectos
`);
}
}
El archivo src/models/Proyecto.js representa la capa de acceso a datos del dominio “proyectos”. Su función es hablar directamente con la base de datos y encargarse exclusivamente de leer, insertar, actualizar y eliminar información relacionada con los proyectos.
Este archivo no decide reglas de negocio ni cómo se responde por HTTP. Su única responsabilidad es convertir operaciones del dominio en consultas SQL.
Qué tipo de tareas realiza
El modelo de Proyecto se encarga de cosas como:
- Obtener la lista de proyectos desde la base de datos
- Buscar un proyecto concreto por su identificador
- Crear un nuevo proyecto
- Actualizar los datos de un proyecto existente
- Eliminar un proyecto
- Calcular estadísticas usando consultas agregadas
Todas estas operaciones se hacen usando la misma conexión compartida que proporciona database.js, lo que refuerza el carácter monolítico del sistema.
Por qué es importante en la arquitectura
Este archivo existe para separar responsabilidades dentro del monolito:
- El modelo se ocupa solo de la base de datos
- Los servicios se ocupan de la lógica de negocio
- Los controladores se ocupan de HTTP
Gracias a esta separación, el código es más fácil de entender, mantener y enseñar. Además, permite cambiar consultas SQL sin afectar al resto del sistema, siempre que la interfaz del modelo se mantenga.
src/models/Tarea.js:
// Importamos la función getDB desde el módulo de base de datos.
// getDB devuelve siempre la MISMA conexión SQLite compartida,
// lo que refuerza el carácter monolítico del sistema.
import { getDB } from '../../database.js';
// Modelo de Tarea.
// Representa la capa de acceso a datos del dominio "tareas".
// Su única responsabilidad es ejecutar consultas SQL sobre la tabla tareas.
export class TareaModel {
// Obtiene todas las tareas asociadas a un proyecto concreto.
// Se ordenan por prioridad y fecha de vencimiento,
// lo que facilita mostrar listas ordenadas en la interfaz.
async findByProyectoId(proyectoId) {
const db = await getDB();
return await db.all(
`SELECT * FROM tareas
WHERE proyecto_id = ?
ORDER BY prioridad, fecha_vencimiento`,
proyectoId
);
}
// Busca una tarea concreta por su id.
// Devuelve la tarea completa o undefined si no existe.
async findById(id) {
const db = await getDB();
return await db.get(
'SELECT * FROM tareas WHERE id = ?',
id
);
}
// Crea una nueva tarea en la base de datos.
// Recibe un objeto tarea con los datos necesarios.
async create(tarea) {
const db = await getDB();
const result = await db.run(
`INSERT INTO tareas (
proyecto_id,
titulo,
descripcion,
asignado_a,
estado,
prioridad,
fecha_vencimiento
)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
tarea.proyecto_id,
tarea.titulo,
tarea.descripcion,
tarea.asignado_a,
tarea.estado || 'pendiente',
tarea.prioridad || 2,
tarea.fecha_vencimiento
]
);
// Devolvemos la tarea creada junto con el id generado.
return { id: result.lastID, ...tarea };
}
// Actualiza todos los campos principales de una tarea existente.
// No se realizan validaciones de negocio aquí.
async update(id, tarea) {
const db = await getDB();
await db.run(
`UPDATE tareas
SET titulo = ?,
descripcion = ?,
asignado_a = ?,
estado = ?,
prioridad = ?,
fecha_vencimiento = ?
WHERE id = ?`,
[
tarea.titulo,
tarea.descripcion,
tarea.asignado_a,
tarea.estado,
tarea.prioridad,
tarea.fecha_vencimiento,
id
]
);
// Se devuelve el objeto actualizado como confirmación.
return { id, ...tarea };
}
// Elimina una tarea por su id.
// La responsabilidad de comprobar permisos o dependencias
// pertenece a capas superiores.
async delete(id) {
const db = await getDB();
await db.run(
'DELETE FROM tareas WHERE id = ?',
id
);
}
// Actualiza únicamente el estado de una tarea.
// Este método es útil para cambios rápidos de workflow
// sin necesidad de actualizar todos los campos.
async updateEstado(id, estado) {
const db = await getDB();
await db.run(
'UPDATE tareas SET estado = ? WHERE id = ?',
[estado, id]
);
}
}
El archivo src/models/Tarea.js representa la capa de acceso a datos del dominio “tareas”. Su función es encargarse exclusivamente de interactuar con la tabla de tareas en la base de datos, sin conocer nada sobre HTTP, validaciones de negocio o comportamiento de la interfaz.
Este modelo es el responsable de ejecutar las consultas SQL relacionadas con las tareas y devolver los datos ya listos para ser usados por capas superiores.
Qué responsabilidades tiene exactamente
Este archivo se ocupa de operaciones muy concretas y bien delimitadas:
- Obtener todas las tareas asociadas a un proyecto
- Buscar una tarea concreta por su identificador
- Crear nuevas tareas en la base de datos
- Actualizar los datos de una tarea existente
- Cambiar únicamente el estado de una tarea
- Eliminar tareas
Todas estas acciones se realizan usando la misma conexión SQLite compartida, proporcionada por database.js, lo que refuerza la idea de un sistema monolítico con estado común.
Por qué este modelo es necesario
Separar las tareas en su propio modelo permite que el código sea más claro y mantenible. El modelo de tareas no toma decisiones de negocio ni valida reglas como estados permitidos o prioridades válidas. Su trabajo es simplemente persistir y recuperar datos.
Esto hace que:
- Las consultas SQL estén centralizadas en un solo lugar
- Los servicios puedan combinar tareas y proyectos fácilmente
- El sistema sea fácil de ampliar sin mezclar responsabilidades
Src/models/Tarea.js ayuda a entender que un modelo no es la lógica del sistema, sino la capa que conecta el dominio con la base de datos de forma directa y controlada
src/models/Usuario.js
import { getDB } from '../../database.js';
/**
* Modelo de Usuario para el sistema monolítico.
*
* Idea clave.
* Este modelo accede a la misma base de datos SQLite compartida por todo el sistema.
* En un monolito, es habitual que distintos módulos compartan conexión y esquema.
*/
export class UsuarioModel {
/**
* Buscar usuario por email.
* Devuelve campos “seguros” (no devuelve password).
*/
async findByEmail(email) {
const db = await getDB(); // Conexión Singleton compartida.
return await db.get(
'SELECT id, email, nombre, rol, created_at FROM usuarios WHERE email = ?',
[email] // Parametrizado para evitar inyección SQL.
);
}
/**
* Buscar usuario por ID.
* Útil para perfil, administración o para refrescar datos desde BD.
*/
async findById(id) {
const db = await getDB();
return await db.get(
'SELECT id, email, nombre, rol, direccion, telefono, created_at FROM usuarios WHERE id = ?',
[id]
);
}
/**
* Crear un nuevo usuario.
* Recibe un objeto con los datos de usuario (password debe venir ya hasheada).
* Devuelve un resumen del usuario creado (sin password).
*/
async create(usuario) {
const db = await getDB();
// Validación de unicidad a nivel aplicación.
// Nota: lo ideal es que la tabla tenga además un UNIQUE(email) para garantizarlo.
const existente = await this.findByEmail(usuario.email);
if (existente) {
throw new Error('El usuario con este email ya existe');
}
// Insert de usuario.
const result = await db.run(
`INSERT INTO usuarios (email, password, nombre, rol, direccion, telefono)
VALUES (?, ?, ?, ?, ?, ?)`,
[
usuario.email,
usuario.password,
usuario.nombre,
usuario.rol || 'cliente',
usuario.direccion || null,
usuario.telefono || null
]
);
// lastID es el id autogenerado por SQLite.
return {
id: result.lastID,
email: usuario.email,
nombre: usuario.nombre,
rol: usuario.rol || 'cliente'
};
}
/**
* Actualizar información de usuario.
* Construye la query dinámicamente con los campos proporcionados.
* Esto permite un “update parcial” sin tocar campos no enviados.
*/
async update(id, datos) {
const db = await getDB();
// Listas para construir: "campo = ?" y sus valores.
const campos = [];
const valores = [];
// Si el campo existe (aunque sea string vacío), se actualiza.
if (datos.nombre !== undefined) {
campos.push('nombre = ?');
valores.push(datos.nombre);
}
if (datos.direccion !== undefined) {
campos.push('direccion = ?');
valores.push(datos.direccion);
}
if (datos.telefono !== undefined) {
campos.push('telefono = ?');
valores.push(datos.telefono);
}
if (datos.rol !== undefined) {
campos.push('rol = ?');
valores.push(datos.rol);
}
// Si no llegó ningún campo actualizable, no hacemos UPDATE.
if (campos.length === 0) {
throw new Error('No se proporcionaron datos para actualizar');
}
// El último valor corresponde al WHERE id = ?
valores.push(id);
await db.run(
`UPDATE usuarios SET ${campos.join(', ')} WHERE id = ?`,
valores
);
// Devolvemos el usuario actualizado consultándolo de nuevo.
return await this.findById(id);
}
/**
* Verificar credenciales de usuario.
* Importante.
* Este método compara password directamente en SQL.
* Solo es correcto si "password" ya es el hash y "password" recibido también.
* En sistemas reales, se suele:
* 1) obtener el hash por email
* 2) comparar con bcrypt en la capa de servicio.
*/
async verificarCredenciales(email, password) {
const db = await getDB();
return await db.get(
'SELECT id, email, nombre, rol FROM usuarios WHERE email = ? AND password = ?',
[email, password]
);
}
/**
* Obtener todos los usuarios con paginación.
* limit y offset controlan cuántos se devuelven y desde dónde.
*/
async findAll(limit = 100, offset = 0) {
const db = await getDB();
return await db.all(
'SELECT id, email, nombre, rol, created_at FROM usuarios ORDER BY created_at DESC LIMIT ? OFFSET ?',
[limit, offset]
);
}
/**
* Estadísticas globales de usuarios.
* Útil para dashboards administrativos.
*/
async getEstadisticas() {
const db = await getDB();
return await db.get(`
SELECT
COUNT(*) as total_usuarios,
SUM(CASE WHEN rol = 'admin' THEN 1 ELSE 0 END) as total_admins,
SUM(CASE WHEN rol = 'cliente' THEN 1 ELSE 0 END) as total_clientes,
COUNT(DISTINCT DATE(created_at)) as dias_con_registros
FROM usuarios
`);
}
/**
* Buscar usuarios por nombre o email.
* Usa LIKE con comodines para búsquedas parciales.
*/
async search(query) {
const db = await getDB();
const searchTerm = `%${query}%`;
return await db.all(
`SELECT id, email, nombre, rol, created_at
FROM usuarios
WHERE nombre LIKE ? OR email LIKE ?
ORDER BY nombre`,
[searchTerm, searchTerm]
);
}
/**
* Eliminar un usuario.
* Incluye regla de negocio: no borrar al último admin.
*/
async delete(id) {
const db = await getDB();
// Verificamos existencia para dar un error claro.
const usuario = await this.findById(id);
if (!usuario) {
throw new Error('Usuario no encontrado');
}
// Regla: evitar borrar al único administrador.
if (usuario.rol === 'admin') {
const estadisticas = await this.getEstadisticas();
if (estadisticas.total_admins <= 1) {
throw new Error('No se puede eliminar al único administrador del sistema');
}
}
// Eliminación final.
await db.run('DELETE FROM usuarios WHERE id = ?', [id]);
return true;
}
}
El archivo src/models/Usuario.js representa la capa de acceso a datos del dominio “usuarios”. Su función principal es gestionar toda la interacción con la tabla de usuarios de la base de datos, manteniendo esta lógica aislada del resto del sistema.
Este archivo se encarga de persistir y recuperar información de usuarios, pero no decide cómo se autentican las peticiones HTTP ni cómo se aplican flujos de negocio completos. Su responsabilidad se limita a hablar con la base de datos de forma directa y controlada.
Qué tipo de operaciones gestiona
El modelo de Usuario se ocupa de acciones típicas relacionadas con usuarios, como:
- Buscar usuarios por email o por identificador
- Crear nuevos usuarios en la base de datos
- Actualizar información de perfil de forma parcial
- Verificar credenciales a nivel de datos
- Listar usuarios con paginación
- Obtener estadísticas globales de usuarios
- Eliminar usuarios aplicando reglas básicas de integridad
Todas estas operaciones usan la misma conexión SQLite compartida que utiliza el resto del sistema, lo que refuerza el carácter monolítico de la aplicación.
Qué lo diferencia de otros modelos
A diferencia de proyectos o tareas, el modelo de usuarios introduce reglas mínimas relacionadas con integridad y seguridad, como evitar emails duplicados o impedir borrar al último administrador. Aun así, estas reglas siguen siendo cercanas a los datos y no al flujo completo de la aplicación.
El modelo no decide quién puede llamar a estas funciones ni desde qué endpoint, solo garantiza que las operaciones sobre usuarios sean coherentes a nivel de base de datos.
Valor del monolito
Desde un punto de vista educativo, src/models/Usuario.js muestra cómo un modelo puede crecer en responsabilidades sin romper la arquitectura, siempre que se mantenga centrado en datos. Ayuda a entender que un monolito bien organizado no es desordenado, sino que separa claramente acceso a datos, lógica de negocio y capa HTTP, incluso cuando todo vive en el mismo proceso
Paso 4: Servicios (Lógica de Negocio)
src/services/proyectoService.js:
// Importamos los modelos del dominio.
// En este diseño, los modelos acceden a la base de datos (vía getDB o similar),
// y el servicio coordina operaciones y aplica reglas de negocio.
import { ProyectoModel } from '../models/Proyecto.js';
import { TareaModel } from '../models/Tarea.js';
// Servicio de Proyectos.
// En un monolito, es habitual que un servicio use varios modelos a la vez
// y combine resultados en memoria sin necesidad de comunicación entre procesos.
export class ProyectoService {
constructor() {
// Creamos instancias de los modelos.
// En un monolito esto es cómodo porque todo vive en el mismo runtime.
// En un enfoque más desacoplado, se suele inyectar estas dependencias.
this.proyectoModel = new ProyectoModel();
this.tareaModel = new TareaModel();
}
// Devuelve la lista de proyectos con datos agregados (si el modelo lo incluye).
// Aquí la lógica es simple: delegar en el modelo.
async obtenerProyectos() {
return await this.proyectoModel.findAll();
}
// Devuelve un proyecto junto con sus tareas.
// Este método es un ejemplo claro de "orquestación" a nivel de servicio:
// combina varias consultas y construye un DTO final para el controlador/vista.
async obtenerProyectoConTareas(id) {
// 1) Buscar el proyecto.
// Si no existe, lanzamos error de negocio.
const proyecto = await this.proyectoModel.findById(id);
if (!proyecto) {
throw new Error('Proyecto no encontrado');
}
// 2) Buscar tareas asociadas al proyecto.
// En un monolito, esto es una llamada local y rápida al modelo.
const tareas = await this.tareaModel.findByProyectoId(id);
// 3) Componer la respuesta.
// Además del proyecto y sus tareas, calculamos un campo derivado "progreso".
return {
...proyecto,
tareas,
progreso: this.calcularProgreso(tareas)
};
}
// Crea un nuevo proyecto aplicando reglas de negocio.
async crearProyecto(proyectoData) {
// Validación de negocio antes de persistir.
// Esto evita que reglas queden dispersas en controladores o modelos.
this.validarProyecto(proyectoData);
// Persistimos el proyecto usando el modelo.
return await this.proyectoModel.create(proyectoData);
}
// Elimina un proyecto.
// Nota: la atomicidad real depende de la base de datos y del uso de transacciones.
// Aquí se confía en ON DELETE CASCADE para eliminar tareas relacionadas.
async eliminarProyecto(id) {
// Primero comprobamos que exista.
const proyecto = await this.proyectoModel.findById(id);
if (!proyecto) {
throw new Error('Proyecto no encontrado');
}
// Eliminamos el proyecto.
// Si la FK está bien definida con ON DELETE CASCADE,
// las tareas dependientes se eliminarán automáticamente.
await this.proyectoModel.delete(id);
// Devolvemos un mensaje "amigable" para capa superior.
// En sistemas distribuidos, garantizar "tareasEliminadas" suele requerir
// más coordinación (colas, sagas, eventos, etc.).
return {
mensaje: `Proyecto "${proyecto.nombre}" eliminado correctamente`,
tareasEliminadas: true
};
}
// Calcula el progreso del proyecto en base a sus tareas.
// Es lógica de negocio pura: no debería vivir en el modelo (SQL)
// ni en el controlador (HTTP).
calcularProgreso(tareas) {
if (tareas.length === 0) return 0;
// Contamos cuántas tareas tienen estado "completada".
const completadas = tareas.filter(
t => t.estado === 'completada'
).length;
// Porcentaje redondeado de completadas sobre total.
return Math.round((completadas / tareas.length) * 100);
}
// Valida reglas del proyecto antes de crear/actualizar.
// Centralizar esto en el servicio evita duplicación.
validarProyecto(proyecto) {
// Regla: nombre obligatorio y mínimo 3 caracteres (ignorando espacios).
if (!proyecto.nombre || proyecto.nombre.trim().length < 3) {
throw new Error('El nombre del proyecto debe tener al menos 3 caracteres');
}
// Regla: si hay fechas, la fecha_fin no puede ser anterior a fecha_inicio.
if (proyecto.fecha_fin && proyecto.fecha_inicio) {
const inicio = new Date(proyecto.fecha_inicio);
const fin = new Date(proyecto.fecha_fin);
if (fin < inicio) {
throw new Error('La fecha de fin no puede ser anterior a la fecha de inicio');
}
}
}
// Devuelve estadísticas agregadas del sistema.
// Delegamos en el modelo, que ya tiene una consulta específica.
async obtenerEstadisticas() {
return await this.proyectoModel.getEstadisticas();
}
}
El archivo src/services/proyectoService.js representa la capa de lógica de negocio del dominio “proyectos”. Su función principal es coordinar operaciones, aplicar reglas del dominio y construir respuestas coherentes combinando varios modelos cuando es necesario.
Este archivo actúa como intermediario entre los controladores HTTP y los modelos de datos. Los controladores no toman decisiones complejas y los modelos no conocen reglas de negocio. Todo eso vive aquí.
Qué tipo de responsabilidades asume
El servicio de proyectos se encarga de tareas que van más allá de una simple consulta a la base de datos, como:
- Validar datos antes de crear o modificar proyectos
- Comprobar que un proyecto existe antes de operar con él
- Combinar información de proyectos y tareas en una sola respuesta
- Calcular valores derivados como el progreso de un proyecto
- Orquestar eliminaciones asegurando coherencia del sistema
- Centralizar reglas del dominio para evitar duplicación
Aquí es donde se decide qué está permitido y cómo se comporta el sistema, no solo qué datos se guardan.
Por qué este archivo es clave en el monolito
En una arquitectura monolítica, el servicio aprovecha que todo vive en el mismo proceso:
- Puede llamar a varios modelos de forma directa y rápida
- Puede combinar datos en memoria sin latencia ni red
- Puede aplicar lógica compleja sin coordinar servicios externos
Esto hace que el flujo sea fácil de seguir: controlador → servicio → modelos → base de datos.
Valor del servicio
Src/services/proyectoService.js deja muy claro un concepto fundamental: la lógica de negocio no debe vivir ni en la base de datos ni en los endpoints HTTP.
Este archivo enseña cómo estructurar un monolito de forma limpia, mostrando que aunque todo esté en una sola aplicación, cada capa tiene un propósito claro y bien definido
src/services/tareaService.js:
// Importamos los modelos necesarios.
// Este servicio usa dos modelos: tareas y proyectos.
// En un monolito es muy común que un servicio combine dominios
// porque todo está disponible en el mismo proceso y con la misma base de datos.
import { TareaModel } from '../models/Tarea.js';
import { ProyectoModel } from '../models/Proyecto.js';
// Servicio de Tareas.
// Su responsabilidad es aplicar reglas de negocio y coordinar operaciones
// entre modelos, manteniendo a los controladores libres de lógica.
export class TareaService {
constructor() {
// Creamos instancias de los modelos.
// Este patrón es típico en monolitos: fácil de leer y rápido de montar,
// aunque menos flexible que la inyección de dependencias.
this.tareaModel = new TareaModel();
this.proyectoModel = new ProyectoModel();
}
// Devuelve todas las tareas de un proyecto.
// Incluye una validación previa para evitar consultar tareas de un proyecto inexistente.
async obtenerTareasDeProyecto(proyectoId) {
// 1) Verificar que el proyecto existe.
// Esto evita respuestas ambiguas (por ejemplo, lista vacía)
// cuando el problema real es que el proyecto no existe.
const proyecto = await this.proyectoModel.findById(proyectoId);
if (!proyecto) {
throw new Error('Proyecto no encontrado');
}
// 2) Obtener tareas del proyecto.
return await this.tareaModel.findByProyectoId(proyectoId);
}
// Crea una tarea nueva.
// Valida integridad (proyecto existe) y reglas de negocio (título, prioridad, etc.).
async crearTarea(tareaData) {
// Verificar que el proyecto asociado existe.
// En sistemas con claves foráneas esto también podría fallar en la BD,
// pero validarlo aquí permite dar un error más claro.
const proyecto = await this.proyectoModel.findById(tareaData.proyecto_id);
if (!proyecto) {
throw new Error('Proyecto no encontrado');
}
// Validaciones de negocio del contenido de la tarea.
// Estas reglas no deberían vivir en el controlador ni en el modelo.
this.validarTarea(tareaData);
// Crear la tarea delegando en el modelo (persistencia).
return await this.tareaModel.create(tareaData);
}
// Actualiza el estado de una tarea.
// Centraliza los estados permitidos y permite añadir efectos colaterales
// (notificaciones, métricas) sin tocar controladores.
async actualizarEstadoTarea(id, nuevoEstado) {
// Lista de estados válidos del flujo de trabajo.
// Esto es una regla de dominio: el servicio es el lugar adecuado.
const estadosValidos = ['pendiente', 'en_progreso', 'completada', 'bloqueada'];
// Validación: no permitir valores fuera del workflow.
if (!estadosValidos.includes(nuevoEstado)) {
throw new Error(
`Estado inválido. Estados permitidos: ${estadosValidos.join(', ')}`
);
}
// Persistimos el cambio de estado.
// El modelo hace el UPDATE, el servicio decide si se puede.
await this.tareaModel.updateEstado(id, nuevoEstado);
// En un monolito podemos ejecutar efectos colaterales en memoria
// inmediatamente después del cambio, sin colas ni redes.
if (nuevoEstado === 'completada') {
this.registrarCompletacionTarea(id);
}
// Devolvemos un resumen útil para la capa HTTP.
return { id, estado: nuevoEstado };
}
// Validaciones de negocio de una tarea.
// Mantenerlas aquí evita duplicación en diferentes endpoints.
validarTarea(tarea) {
// Regla: título obligatorio y mínimo 3 caracteres (ignorando espacios).
if (!tarea.titulo || tarea.titulo.trim().length < 3) {
throw new Error('El título de la tarea debe tener al menos 3 caracteres');
}
// Regla: prioridad (si viene) debe estar entre 1 y 3.
// Nota: 1 alta, 2 media, 3 baja (según comentario del error).
if (tarea.prioridad && (tarea.prioridad < 1 || tarea.prioridad > 3)) {
throw new Error('La prioridad debe ser un número entre 1 (alta) y 3 (baja)');
}
}
// Registra la completación de una tarea.
// Aquí se simula un efecto colateral típico: logging, notificación, métricas, etc.
// En monolito es fácil porque todo está en el mismo proceso.
registrarCompletacionTarea(tareaId) {
// Ejemplo simple: escribir en el log del proceso.
// En una app real podrías:
// - Insertar en una tabla de auditoría
// - Enviar un email
// - Actualizar métricas internas
console.log(`📝 Tarea ${tareaId} completada - Registrado en log interno`);
// Nota: este método no es async y no persiste nada.
// Es puramente demostrativo del concepto "side effects" en monolitos.
}
}
El archivo src/services/tareaService.js representa la capa de lógica de negocio del dominio “tareas”. Su función es decidir cómo se comportan las tareas dentro del sistema, aplicando reglas del dominio y coordinando operaciones entre modelos antes de que los datos lleguen o salgan de la base de datos.
Este servicio actúa como intermediario entre las rutas HTTP y los modelos, evitando que los controladores contengan lógica y que los modelos tomen decisiones que no les corresponden.
Qué responsabilidades asume este servicio
El servicio de tareas se encarga de aspectos que van más allá de guardar o leer datos, como:
- Comprobar que un proyecto existe antes de crear o consultar tareas
- Validar reglas del dominio, como títulos mínimos o prioridades válidas
- Controlar los estados permitidos del ciclo de vida de una tarea
- Centralizar los cambios de estado y sus efectos asociados
- Coordinar operaciones entre tareas y proyectos de forma coherente
Aquí es donde se definen las normas del sistema relacionadas con las tareas.
Por qué este servicio es importante en un monolito
En un monolito, el servicio de tareas puede comunicarse directamente con otros modelos sin coste adicional:
- No hay llamadas HTTP internas
- No hay colas ni mensajes intermedios
- Todo ocurre dentro del mismo proceso y memoria
Esto permite que el servicio combine validaciones, consultas y efectos colaterales de forma sencilla y fácil de seguir, algo especialmente valioso en un entorno formativo.
Valor dentro de la arquitectura
Src/services/tareaService.js muestra claramente que las reglas del negocio pertenecen a los servicios, no a los endpoints ni a las consultas SQL. Ayuda a entender cómo estructurar aplicaciones reales manteniendo el código ordenado, predecible y fácil de mantener, incluso cuando todo vive dentro de una única aplicación monolítica
Paso 5: Rutas de la API
src/routes/proyectos.js:
// Importamos Express para poder crear un router independiente.
// Este router agrupa todas las rutas relacionadas con el dominio "proyectos".
import express from 'express';
// Importamos el servicio de proyectos.
// El router no accede a modelos ni a la base de datos directamente;
// toda la lógica pasa por el servicio.
import { ProyectoService } from '../services/proyectoService.js';
// Función fábrica que crea y devuelve el router de proyectos.
// En un monolito, este router se monta dentro de la app principal
// y comparte proceso, memoria, sesiones y middlewares globales.
export function crearRouterProyectos() {
// Creamos una nueva instancia del router de Express.
const router = express.Router();
// Instanciamos el servicio.
// En este diseño, el servicio se crea una vez por router
// y se reutiliza en todas las rutas.
const proyectoService = new ProyectoService();
// GET /api/proyectos
// Devuelve la lista completa de proyectos.
// Esta ruta suele alimentar listados o dashboards.
router.get('/', async (req, res) => {
try {
// Delegamos toda la lógica al servicio.
const proyectos = await proyectoService.obtenerProyectos();
// Respondemos con JSON.
res.json(proyectos);
} catch (error) {
// Error genérico del servidor.
res.status(500).json({ error: error.message });
}
});
// GET /api/proyectos/estadisticas
// Devuelve estadísticas globales del sistema de proyectos.
// Normalmente usado para paneles de control.
router.get('/estadisticas', async (req, res) => {
try {
const estadisticas =
await proyectoService.obtenerEstadisticas();
res.json(estadisticas);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/proyectos/:id
// Devuelve un proyecto concreto junto con sus tareas.
// El id llega como string en req.params y se convierte a número.
router.get('/:id', async (req, res) => {
try {
const proyecto =
await proyectoService.obtenerProyectoConTareas(
parseInt(req.params.id)
);
res.json(proyecto);
} catch (error) {
// Si el proyecto no existe, el servicio lanza un error.
// Aquí lo traducimos a un 404.
res.status(404).json({ error: error.message });
}
});
// POST /api/proyectos
// Crea un nuevo proyecto.
// Los datos se reciben en el body de la petición.
router.post('/', async (req, res) => {
try {
const nuevoProyecto =
await proyectoService.crearProyecto(req.body);
// 201 indica que el recurso se ha creado correctamente.
res.status(201).json(nuevoProyecto);
} catch (error) {
// Errores típicos: validaciones de negocio.
res.status(400).json({ error: error.message });
}
});
// DELETE /api/proyectos/:id
// Elimina un proyecto y, gracias al DELETE CASCADE,
// también elimina todas sus tareas asociadas.
router.delete('/:id', async (req, res) => {
try {
const resultado =
await proyectoService.eliminarProyecto(
parseInt(req.params.id)
);
res.json(resultado);
} catch (error) {
// Error si el proyecto no existe o no puede eliminarse.
res.status(400).json({ error: error.message });
}
});
// Devolvemos el router configurado.
// La app principal decidirá en qué path montarlo (por ejemplo /api/proyectos).
return router;
}
El archivo src/routes/proyectos.js define la capa de entrada HTTP del dominio “proyectos”. Su función es recibir las peticiones del cliente, extraer los datos necesarios de la URL o del cuerpo de la petición y delegar toda la lógica real al servicio correspondiente.
Este archivo no contiene reglas de negocio ni consultas a la base de datos. Su papel es exclusivamente traducir HTTP a llamadas de aplicación y devolver respuestas HTTP adecuadas.
Qué responsabilidades tiene este archivo
Este router se encarga de:
- Definir las rutas HTTP relacionadas con proyectos
- Asociar cada ruta a una acción concreta del servicio de proyectos
- Convertir parámetros de la URL a valores utilizables por el backend
- Decidir qué código HTTP devolver según el resultado de la operación
- Capturar errores y transformarlos en respuestas comprensibles para el cliente
Actúa como una frontera clara entre el mundo web y la lógica interna del sistema.
Por qué es importante dentro del monolito
En una arquitectura monolítica, todas las rutas viven en el mismo servidor y proceso, pero siguen estando organizadas por dominio. Este archivo permite que el sistema:
- Mantenga el código HTTP separado del resto de capas
- Sea fácil de leer y mantener a medida que crecen los endpoints
- Tenga una estructura coherente y predecible
Cada router agrupa un conjunto de rutas relacionadas, lo que mejora la claridad tanto técnica como pedagógica.
Valor del router de proyectos
Desde el punto de vista educativo, src/routes/proyectos.js muestra claramente que una ruta no es lógica de negocio, sino un adaptador. Enseña a estructurar APIs donde los controladores son delgados, fáciles de entender y centrados solo en HTTP, dejando el trabajo importante a las capas internas del sistema
src/routes/tareas.js:
// Importamos Express para crear un router independiente.
// Este router agrupa todas las rutas relacionadas con el dominio "tareas".
import express from 'express';
// Importamos el servicio de tareas.
// El router no accede directamente a la base de datos ni a los modelos.
import { TareaService } from '../services/tareaService.js';
// Función fábrica que crea y devuelve el router de tareas.
// En un monolito, este router se monta dentro de la app principal
// y comparte el mismo proceso, memoria y conexión a base de datos.
export function crearRouterTareas() {
// Creamos una nueva instancia del router de Express.
const router = express.Router();
// Instanciamos el servicio de tareas.
// Este servicio coordina modelos y aplica reglas de negocio.
const tareaService = new TareaService();
// GET /api/proyectos/:proyectoId/tareas
// Devuelve todas las tareas asociadas a un proyecto concreto.
router.get('/proyectos/:proyectoId/tareas', async (req, res) => {
try {
// El id del proyecto llega como string en la URL.
// Se convierte a número antes de pasarlo al servicio.
const tareas = await tareaService.obtenerTareasDeProyecto(
parseInt(req.params.proyectoId)
);
// Respondemos con la lista de tareas en formato JSON.
res.json(tareas);
} catch (error) {
// Si el proyecto no existe, el servicio lanza un error.
// Aquí lo traducimos a un 404 (recurso no encontrado).
res.status(404).json({ error: error.message });
}
});
// POST /api/tareas
// Crea una nueva tarea asociada a un proyecto.
// Los datos se reciben en el body de la petición.
router.post('/', async (req, res) => {
try {
// Delegamos toda la lógica al servicio:
// validaciones, comprobación de proyecto y persistencia.
const nuevaTarea = await tareaService.crearTarea(req.body);
// 201 indica que el recurso se ha creado correctamente.
res.status(201).json(nuevaTarea);
} catch (error) {
// Errores típicos aquí: validaciones de negocio.
res.status(400).json({ error: error.message });
}
});
// PATCH /api/tareas/:id/estado
// Actualiza únicamente el estado de una tarea existente.
// Es un ejemplo de endpoint parcial (PATCH).
router.patch('/:id/estado', async (req, res) => {
try {
// Extraemos el id de la tarea desde la URL
// y el nuevo estado desde el body.
const tareaActualizada =
await tareaService.actualizarEstadoTarea(
parseInt(req.params.id),
req.body.estado
);
// Respondemos con un resumen del cambio realizado.
res.json(tareaActualizada);
} catch (error) {
// Error si el estado es inválido o la tarea no existe.
res.status(400).json({ error: error.message });
}
});
// Devolvemos el router configurado.
// La app principal decidirá dónde montarlo
// (por ejemplo, app.use('/api/tareas', router)).
return router;
}
El archivo src/routes/tareas.js define la capa de entrada HTTP del dominio “tareas”. Su función es recibir peticiones relacionadas con las tareas, interpretar los datos que llegan desde la URL o el cuerpo de la petición y delegar toda la lógica real al servicio de tareas.
Este archivo no decide reglas del dominio ni interactúa directamente con la base de datos. Su papel es conectar el mundo HTTP con la lógica interna del sistema.
Qué responsabilidades tiene este router
El router de tareas se encarga de:
- Definir los endpoints relacionados con las tareas
- Extraer parámetros como identificadores o estados desde la petición
- Llamar al servicio de tareas con los datos correctos
- Devolver respuestas HTTP claras y coherentes
- Traducir errores de negocio en códigos HTTP adecuados
Cada ruta representa una acción concreta del sistema, pero la decisión de si esa acción es válida o no siempre recae en el servicio.
Su papel dentro del monolito
Aunque todo el sistema viva en un único proceso, este archivo ayuda a mantener el orden:
- Las rutas solo saben de HTTP
- Los servicios saben de reglas de negocio
- Los modelos saben de base de datos
Esta separación hace que el monolito sea entendible, mantenible y fácil de ampliar, incluso a medida que crecen los endpoints.
Valor del router de tareas
Desde un punto de vista educativo, src/routes/tareas.js refuerza una idea clave: una ruta no es lógica del sistema, es solo un adaptador entre el cliente y la aplicación. Ayuda a entender cómo estructurar APIs limpias, donde cada capa tiene una responsabilidad clara, incluso dentro de una arquitectura monolítica bien diseñada
Paso 6: Servidor Principal (Punto de Entrada Único)
server.js:
// Archivo principal de arranque del monolito.
// Ensambla Express, middlewares globales, routers de API y el servido del frontend (SPA).
import express from 'express';
// Routers del dominio.
// Aunque estén “separados” en archivos, siguen viviendo en el mismo proceso.
import { crearRouterProyectos } from './src/routes/proyectos.js';
import { crearRouterTareas } from './src/routes/tareas.js';
// Acceso a la base de datos compartida (Singleton).
// getDB devuelve siempre la misma conexión a SQLite para toda la app.
import { getDB } from './database.js';
import path from 'path';
import { fileURLToPath } from 'url';
// Configuración para ES Modules.
// En ESM no existe __dirname por defecto, por eso se reconstruye así.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Crear la aplicación Express.
// Representa el backend completo del sistema dentro de un único runtime.
const app = express();
// Puerto configurable por entorno (hosting / contenedores) o 3000 por defecto.
const PORT = process.env.PORT || 3000;
// =======================
// MIDDLEWARE GLOBAL
// =======================
// Parsear cuerpos JSON en TODA la aplicación.
// Afecta a todos los endpoints que reciban JSON.
app.use(express.json());
// Servir archivos estáticos desde /public.
// Aquí suele estar el frontend (HTML, CSS, JS, imágenes, etc.).
app.use(express.static(path.join(__dirname, 'public')));
// =======================
// API ROUTERS
// =======================
// Montar el router de proyectos bajo /api/proyectos.
// Todas las rutas declaradas en ese router quedan “prefijadas” con este path.
app.use('/api/proyectos', crearRouterProyectos());
// Montar el router de tareas bajo /api/tareas.
app.use('/api/tareas', crearRouterTareas());
// =======================
// HEALTH CHECK
// =======================
// Ruta típica para monitorización.
// Comprueba que el proceso está vivo y que la BD responde.
app.get('/api/health', async (req, res) => {
try {
// Pedimos la conexión compartida de SQLite.
const db = await getDB();
// Consulta mínima para validar conectividad.
const dbStatus = await db.get('SELECT 1 as ok');
// Información de “salud” del sistema.
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
components: {
database: dbStatus.ok ? 'connected' : 'disconnected',
api: 'running',
memory: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB used`
},
architecture: 'monolithic'
});
} catch (error) {
// Si algo falla, respondemos unhealthy con 500.
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
// =======================
// INFO DEL MONOLITO
// =======================
// Endpoint: describe características, ventajas y limitaciones.
app.get('/api/monolito-info', (req, res) => {
res.json({
caracteristicas: [
'Proceso único de Node.js',
'Base de datos SQLite compartida',
'Comunicación por llamadas a funciones (no HTTP entre componentes)',
'Stack traces completos en errores',
'Estado en memoria compartido',
'Testing integral simplificado'
],
ventajas: [
'Desarrollo rápido - todo en un repositorio',
'Debugging sencillo - stack traces completos',
'Despliegue simple - un solo artefacto',
'Transacciones ACID - consistencia garantizada'
],
limitaciones: [
'Acoplamiento fuerte entre módulos',
'Escalado todo o nada',
'Tecnología homogénea obligatoria',
'Punto único de fallo'
]
});
});
// =======================
// SERVIR EL FRONTEND (SPA)
// =======================
// En lugar de usar app.get('*'), se definen rutas explícitas.
// Esto evita interferencias con rutas de API y hace más controlable el enrutado.
app.get('/', (req, res) => {
// Sirve el HTML principal del frontend.
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/proyectos', (req, res) => {
// Ruta “amigable” del frontend (cliente).
// En una SPA, el frontend decide qué vista mostrar.
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/tareas', (req, res) => {
// Otra ruta de frontend que también debe cargar la SPA.
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Fallback para SPA, más robusto.
// Si no es /api y no parece un archivo estático (no contiene extensión),
// devolvemos index.html para que el router del frontend maneje la ruta.
app.use((req, res, next) => {
if (!req.path.startsWith('/api') && !req.path.includes('.')) {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
} else {
next();
}
});
// =======================
// ARRANQUE DEL SERVIDOR
// =======================
// Inicia el servidor HTTP.
// Todo (API + frontend) vive en el mismo proceso y el mismo despliegue.
app.listen(PORT, async () => {
console.log(`Servidor monolítico ejecutándose en http://localhost:${PORT}`);
console.log(`API disponible en http://localhost:${PORT}/api/proyectos`);
console.log(`Health check: http://localhost:${PORT}/api/health`);
console.log(`Info monolito: http://localhost:${PORT}/api/monolito-info`);
console.log('');
console.log('CARACTERÍSTICAS DEL MONOLITO DEMOSTRADAS');
console.log('Todo en un proceso Node.js');
console.log('Base de datos única (SQLite)');
console.log('Comunicación intra-proceso');
console.log('Estado compartido en memoria');
console.log('Despliegue simple');
});
// =======================
// CIERRE LIMPIO
// =======================
// Manejo de SIGTERM: típico en despliegues con contenedores o sistemas de hosting.
// Aquí se hace exit directo.
// En un cierre más completo, también se cerraría la conexión a la base de datos.
process.on('SIGTERM', async () => {
console.log('Recibida señal SIGTERM, cerrando servidor monolítico...');
process.exit(0);
});
El archivo server.js es el punto de entrada y arranque de toda la aplicación monolítica. Su función es ensamblar todas las piezas del sistema y ponerlo en funcionamiento como un único proceso.
Es el archivo que se ejecuta cuando se inicia el servidor y el responsable de que backend y frontend vivan juntos en la misma aplicación.
Qué responsabilidades tiene
server.js se encarga de tareas globales y estructurales, como:
- Crear y configurar la aplicación Express
- Definir middlewares comunes para toda la aplicación
- Montar los routers de la API por dominio
- Servir los archivos del frontend estático
- Exponer endpoints técnicos como el health check
- Arrancar el servidor HTTP en un puerto concreto
No contiene lógica de negocio ni acceso directo a datos. Su papel es orquestar, no decidir.
Por qué es clave en una arquitectura monolítica
En un monolito, todo ocurre dentro de un único proceso. server.js refleja exactamente esa idea:
- Un solo servidor
- Una sola API
- Un solo frontend servido desde el mismo sitio
- Una única conexión compartida a la base de datos
Este archivo deja claro dónde empieza y termina el sistema, y cómo todas las capas se conectan entre sí.
Valor del archivo
Desde un punto de vista educativo, server.js ayuda a entender que una aplicación real necesita un punto central de composición. Enseña que el arranque, la configuración y el cableado de componentes son responsabilidades distintas a la lógica de negocio o al acceso a datos, incluso en un sistema monolítico bien estructurado
Paso 7: Frontend Completo
public/index.html:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sistema de Gestión de Proyectos - Monolítico</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="#">
<i class="bi bi-diagram-3"></i> Gestor de Proyectos Monolítico
</a>
<div class="navbar-nav ms-auto">
<span class="nav-text text-light">
<small>Arquitectura: <strong>Monolito</strong></small>
</span>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- Panel de información del monolito -->
<div class="row mb-4">
<div class="col-12">
<div class="card border-primary">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i> Características de esta Arquitectura Monolítica
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Ventajas demostradas:</h6>
<ul class="list-unstyled">
<li><i class="bi bi-check-circle text-success"></i> Todo en un proceso Node.js</li>
<li><i class="bi bi-check-circle text-success"></i> Base de datos SQLite única</li>
<li><i class="bi bi-check-circle text-success"></i> Debugging simplificado</li>
<li><i class="bi bi-check-circle text-success"></i> Despliegue simple</li>
</ul>
</div>
<div class="col-md-6">
<h6>Limitaciones inherentes:</h6>
<ul class="list-unstyled">
<li><i class="bi bi-exclamation-triangle text-warning"></i> Acoplamiento fuerte</li>
<li><i class="bi bi-exclamation-triangle text-warning"></i> Escalado "todo o nada"</li>
<li><i class="bi bi-exclamation-triangle text-warning"></i> Punto único de fallo</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Panel de Proyectos -->
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-kanban"></i> Proyectos</h5>
<button class="btn btn-sm btn-primary" onclick="mostrarFormularioProyecto()">
<i class="bi bi-plus"></i> Nuevo Proyecto
</button>
</div>
<div class="card-body">
<div id="proyectos-container" class="row">
<!-- Los proyectos se cargan aquí dinámicamente -->
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando proyectos...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Formulario para nuevo proyecto (oculto inicialmente) -->
<div id="form-proyecto" class="card mt-3" style="display: none;">
<div class="card-header">
<h6 class="mb-0">Nuevo Proyecto</h6>
</div>
<div class="card-body">
<form id="proyecto-form">
<div class="mb-3">
<label for="nombre" class="form-label">Nombre del Proyecto *</label>
<input type="text" class="form-control" id="nombre" required minlength="3">
</div>
<div class="mb-3">
<label for="descripcion" class="form-label">Descripción</label>
<textarea class="form-control" id="descripcion" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="fecha_inicio" class="form-label">Fecha Inicio</label>
<input type="date" class="form-control" id="fecha_inicio">
</div>
<div class="col-md-6 mb-3">
<label for="fecha_fin" class="form-label">Fecha Fin</label>
<input type="date" class="form-control" id="fecha_fin">
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-secondary me-2" onclick="ocultarFormularioProyecto()">Cancelar</button>
<button type="submit" class="btn btn-primary">Crear Proyecto</button>
</div>
</form>
</div>
</div>
</div>
<!-- Panel de Estadísticas y Tareas -->
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-graph-up"></i> Estadísticas del Sistema</h6>
</div>
<div class="card-body">
<div id="estadisticas-container">
<div class="text-center">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-list-task"></i> Tareas del Proyecto Seleccionado</h6>
</div>
<div class="card-body">
<div id="tareas-container">
<p class="text-muted text-center">Selecciona un proyecto para ver sus tareas</p>
</div>
</div>
</div>
</div>
</div>
<!-- Modal para ver detalles del proyecto -->
<div class="modal fade" id="proyectoModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalProyectoTitulo"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="modalProyectoContenido"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle -->
<script src="<https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js>"></script>
<!-- Nuestra aplicación JavaScript -->
<script src="app.js"></script>
</body>
</html>
public/styles.css:
/* Estilos específicos para la aplicación monolítica */
/* Estilos para tarjetas de proyectos */
.proyecto-card {
transition: all 0.3s ease;
cursor: pointer;
border-left: 4px solid #0d6efd;
}
.proyecto-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.proyecto-card.activo {
border-left-color: #198754;
}
.proyecto-card.completado {
border-left-color: #6c757d;
}
.proyecto-card.planificacion {
border-left-color: #ffc107;
}
/* Barra de progreso personalizada */
.progress {
height: 8px;
}
/* Badges de estado */
.estado-badge {
font-size: 0.75em;
padding: 0.25em 0.5em;
}
/* Animación para nuevas tareas */
@keyframes highlight {
0% { background-color: rgba(25, 135, 84, 0.1); }
100% { background-color: transparent; }
}
.highlight {
animation: highlight 2s ease;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card {
margin-bottom: 1rem;
}
}
/* Indicador de arquitectura */
.arch-indicator {
font-size: 0.8em;
padding: 0.2em 0.5em;
background: linear-gradient(45deg, #0d6efd, #6610f2);
color: white;
border-radius: 3px;
}
public/app.js:
Parte 1 – Inicialización, carga de proyectos y renderizado principal
// Aplicación frontend para un sistema monolítico.
// El frontend se sirve desde /public y consume la API REST del mismo servidor.
class GestorProyectosApp {
constructor() {
// Almacena el proyecto actualmente seleccionado en la interfaz
this.proyectoSeleccionado = null;
// Inicializa la aplicación
this.init();
}
init() {
// Carga inicial de datos
this.cargarProyectos();
this.cargarEstadisticas();
this.configurarEventos();
// Información educativa sobre la arquitectura
this.mostrarInfoArquitectura();
}
async cargarProyectos() {
try {
// GET /api/proyectos
const response = await fetch('/api/proyectos');
const proyectos = await response.json();
this.mostrarProyectos(proyectos);
} catch (error) {
this.mostrarError('Error al cargar proyectos: ' + error.message);
}
}
async cargarEstadisticas() {
try {
// GET /api/proyectos/estadisticas
const response = await fetch('/api/proyectos/estadisticas');
const estadisticas = await response.json();
this.mostrarEstadisticas(estadisticas);
} catch (error) {
console.error('Error al cargar estadísticas:', error);
}
}
async cargarTareasDeProyecto(proyectoId) {
try {
// GET /api/proyectos/:id
const response = await fetch(`/api/proyectos/${proyectoId}`);
const proyecto = await response.json();
this.mostrarTareas(proyecto.tareas, proyecto.progreso);
} catch (error) {
this.mostrarError('Error al cargar tareas: ' + error.message);
}
}
mostrarProyectos(proyectos) {
const container = document.getElementById('proyectos-container');
container.innerHTML = '';
// Estado vacío
if (proyectos.length === 0) {
container.innerHTML = `
<div class="col-12">
<div class="alert alert-info">
No hay proyectos creados. Crea tu primer proyecto.
</div>
</div>
`;
return;
}
// Render de cada proyecto
proyectos.forEach(proyecto => {
container.innerHTML += this.crearCardProyecto(proyecto);
});
// Evento de selección de proyecto
document.querySelectorAll('.proyecto-card').forEach(card => {
card.addEventListener('click', (e) => {
if (!e.target.closest('.btn-eliminar')) {
this.seleccionarProyecto(card.dataset.id);
}
});
});
}
crearCardProyecto(proyecto) {
const tareasCompletadas = proyecto.tareas_completadas || 0;
const totalTareas = proyecto.total_tareas || 0;
const progreso =
totalTareas > 0
? Math.round((tareasCompletadas / totalTareas) * 100)
: 0;
return `
<div class="col-md-6 mb-3">
<div class="card proyecto-card ${proyecto.estado}" data-id="${proyecto.id}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1">${proyecto.nombre}</h6>
<button class="btn btn-sm btn-outline-danger btn-eliminar"
onclick="app.eliminarProyecto(${proyecto.id}, '${proyecto.nombre}')">
<i class="bi bi-trash"></i>
</button>
</div>
<p class="card-text text-muted small">
${proyecto.descripcion || 'Sin descripción'}
</p>
<div class="mt-2">
<span class="badge bg-${this.getEstadoColor(proyecto.estado)}">
${proyecto.estado}
</span>
<span class="badge bg-secondary ms-1">
${totalTareas} tareas
</span>
</div>
${totalTareas > 0 ? `
<div class="mt-3">
<div class="d-flex justify-content-between small mb-1">
<span>Progreso</span>
<span>${progreso}%</span>
</div>
<div class="progress">
<div class="progress-bar" style="width: ${progreso}%"></div>
</div>
</div>` : ''}
<div class="mt-2 small text-muted">
Creado: ${new Date(proyecto.created_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
`;
}
Parte 2 – Tareas, acciones del usuario, utilidades y arranque
mostrarTareas(tareas, progreso) {
const container = document.getElementById('tareas-container');
if (!tareas || tareas.length === 0) {
container.innerHTML = `
<p class="text-muted text-center">
No hay tareas en este proyecto
</p>
`;
return;
}
let html = `
<div class="mb-3">
<div class="d-flex justify-content-between mb-2">
<span>Progreso total</span>
<strong>${progreso || 0}%</strong>
</div>
<div class="progress mb-3">
<div class="progress-bar" style="width: ${progreso || 0}%"></div>
</div>
</div>
`;
tareas.forEach(tarea => {
html += this.crearItemTarea(tarea);
});
container.innerHTML = html;
document.querySelectorAll('.btn-estado-tarea').forEach(btn => {
btn.addEventListener('click', async () => {
await this.actualizarEstadoTarea(btn.dataset.id, btn.dataset.estado);
await this.cargarTareasDeProyecto(this.proyectoSeleccionado);
await this.cargarProyectos();
await this.cargarEstadisticas();
});
});
}
crearItemTarea(tarea) {
const estadoColores = {
pendiente: 'secondary',
en_progreso: 'primary',
completada: 'success',
bloqueada: 'danger'
};
return `
<div class="tarea-item border-start border-3 border-${estadoColores[tarea.estado]} ps-2 mb-2">
<strong>${tarea.titulo}</strong>
<div class="small text-muted">${tarea.descripcion || ''}</div>
<div class="btn-group btn-group-sm mt-1">
<button class="btn btn-outline-success btn-estado-tarea"
data-id="${tarea.id}" data-estado="completada">
Completar
</button>
</div>
</div>
`;
}
async crearProyecto(datos) {
const response = await fetch('/api/proyectos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
await this.cargarProyectos();
await this.cargarEstadisticas();
}
async eliminarProyecto(id) {
await fetch(`/api/proyectos/${id}`, { method: 'DELETE' });
await this.cargarProyectos();
await this.cargarEstadisticas();
}
async actualizarEstadoTarea(id, estado) {
await fetch(`/api/tareas/${id}/estado`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado })
});
}
seleccionarProyecto(id) {
this.proyectoSeleccionado = id;
this.cargarTareasDeProyecto(id);
}
getEstadoColor(estado) {
const colores = {
activo: 'success',
completado: 'secondary',
planificacion: 'warning'
};
return colores[estado] || 'primary';
}
mostrarEstadisticas(estadisticas) {
document.getElementById('estadisticas-container').innerHTML = `
<strong>${estadisticas.total_proyectos}</strong> proyectos,
<strong>${estadisticas.total_tareas}</strong> tareas
`;
}
mostrarError(msg) {
alert(msg);
console.error(msg);
}
mostrarInfoArquitectura() {
console.log('Arquitectura monolítica: frontend + backend en el mismo servidor');
}
configurarEventos() {
document.getElementById('proyecto-form').addEventListener('submit', async e => {
e.preventDefault();
await this.crearProyecto({
nombre: document.getElementById('nombre').value,
descripcion: document.getElementById('descripcion').value
});
ocultarFormularioProyecto();
e.target.reset();
});
}
}
// Funciones auxiliares globales usadas desde HTML
function mostrarFormularioProyecto() {
document.getElementById('form-proyecto').style.display = 'block';
}
function ocultarFormularioProyecto() {
document.getElementById('form-proyecto').style.display = 'none';
}
// Arranque cuando el DOM está listo
document.addEventListener('DOMContentLoaded', () => {
window.app = new GestorProyectosApp();
});
Paso 8: Instrucciones Completas de Ejecución
Instrucciones paso a paso:
# 7. Modificar package.json para añadir scripts:
# "scripts": {
# "start": "node server.js",
# "dev": "nodemon server.js"
# }
# 8. Ejecutar el servidor
npm run dev
# 9. Acceder a la aplicación en el navegador:
# http://localhost:3000
Características del Monolito Demostradas en este Ejemplo:
- Proceso único: Todo corre en un solo proceso Node.js (
server.js) - Base de datos única: SQLite compartida por todos los componentes
- Comunicación intra-proceso: Modelos y servicios se llaman directamente como funciones
- Frontend y backend integrados: Servidos desde el mismo servidor Express
- Transacciones ACID: DELETE CASCADE en SQLite garantiza consistencia
- Stack traces completos: Errores muestran todo el flujo del código
- Despliegue simple:
npm startinicia todo el sistema