Refactor SOLID: del JavaScript monolítico a una arquitectura reactiva
El News Dashboard de Tech Pulse generaba su HTML en el servidor (SSR), pero la interactividad — filtros, búsqueda, tabs — vivía en un archivo JavaScript de ~200 líneas que acumulabaomal día abrí ese archivo para añadir un filtro nuevo y me encontré con una función onTipoFuenteChange() de más de 80 líneas que manejaba todos los eventos posibles: cambio de categoría, cambio de fuente, cambio de canal, búsqueda, tabs multimedia… todo mezclado en un solo bloque if-else.
No era ilegible, pero era frágil: cada cambio nuevo amenazaba con romper algo existente porque todo dependía de todo. Este es el relato de cómo se refactorizó la arquitectura JavaScript del dashboard siguiendo los principios SOLID, los pasos que se siguieron y qué cambió en el código.
El estado anterior: cómo era el código
El JavaScript original tenía una estructura típica de código que crece orgánicamente. No estaba mal escrito, pero seguía un patrón que no escala:
Variables globales por doquier. Más de 10 variables globales controlaban el estado de la interfaz: currentCategory, currentSource, currentChannel, searchQuery, activeTab, newsFilters, videoFilters, githubQuery… Cada una se leía y escribía directamente desde distintas funciones.
Una función central que lo gestionaba todo. onTipoFuenteChange() era el “cerebro” del dashboard. Cuando el usuario hacía clic en cualquier chip, cambiaba de pestaña o escribía en la búsqueda, esta función se encargaba de todo: leía el estado actual, decidía qué filtrar, actualizaba el DOM y gestionaba la visibilidad de elementos. Tenía más de 80 líneas de lógica condicional anidada.
Funciones de renderizado duplicadas. Había 8 funciones que hacían esencialmente lo mismo: renderizar chips de filtro para diferentes secciones (noticias, vídeos, canales, fuentes, etc.). Cada una creaba el mismo HTML con ligeras variaciones, lo que significaba que un cambio de estilo requería modificar 8 sitios.
Helpers con efectos secundarios. Las funciones auxiliares no solo transformaban datos: también accedían al DOM, modificaban variables globales o hacían fetch. Testearlas requería preparar el DOM completo, lo que disuadía de escribir tests.
Sin gestión de memoria. Los event listeners se registraban con addEventListener pero nunca se eliminaban. Si el dashboard se re-renderizaba (por ejemplo, al cambiar de pestaña), los listeners antiguos seguían activos, acumulándose y causando ejecuciones múltiples del mismo código.
El diseño de la solución: cinco patrones SOLID
Antes de tocar una línea de código, definí qué principios SOLID aplicarían y cómo se materializarían en el contexto del dashboard:
1. Single Responsibility — Store Observable
Cada fragmento de estado visible en la interfaz se centralizó en un único store con una API mínima: get() para leer, set() para escribir y on() para suscribirse a cambios.
// Antes: 10+ variables globales
let currentCategory = 'all';
let currentSource = 'all';
let currentChannel = '';
let searchQuery = '';
// ... 6 más
// Después: un solo store reactiva
const store = {
_state: { category: 'all', source: 'all', channel: '', search: '' },
_listeners: [],
get() { return this._state; },
set(partial) {
Object.assign(this._state, partial);
this._listeners.forEach(fn => fn(this._state));
},
on(fn) {
this._listeners.push(fn);
return () => {
this._listeners = this._listeners.filter(l => l !== fn);
};
}
};
El patrón on() devuelve una función unsubscribe que elimina el listener del array. Esto previene fugas de memoria: cuando un componente se desmonta o un efecto se desactiva, se llama a unsubscribe() y el listener desaparece.
2. Open/Closed — Fábrica genérica de chips
Las 8 funciones de renderizado duplicadas se reemplazaron por dos genéricas: renderChips() y renderCanalChips(). Cualquier sección del dashboard que necesite chips de filtro llama a estas funciones con parámetros, sin necesidad de duplicar lógica.
// Antes: 8 funciones casi idénticas
function renderNewsCategoryChips() { /* ~20 líneas de HTML */ }
function renderVideoCategoryChips() { /* ~20 líneas casi iguales */ }
function renderNewsSourceChips() { /* ~20 líneas más */ }
// ... 5 más
// Después: una sola función genérica
function renderChips(container, items, options) {
container.innerHTML = items.map(item => `
<button class="chip ${item.active ? 'active' : ''}"
data-value="${item.value}">
${options.showIcon ? `<img src="${item.icon}" alt="">` : ''}
${item.label}
</button>
`).join('');
}
Añadir un nuevo tipo de chip ahora requiere una llamada a renderChips() con los datos adecuados, no la escritura de una función nueva.
3. Interface Segregation — Helpers puros
Las funciones auxiliares se separaron en módulos que solo hacen una cosa y no tienen efectos secundarios. No acceden al DOM, no modifican variables globales, no hacen fetch. Solo transforman datos:
function limpiarFuente(fuente) {
return fuente.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
function tipoFuente(item) {
if (item.id_video) return 'video';
if (item.fuente_rss) return 'rss';
return 'news';
}
function itemEnSemana(item, dias = 7) {
const fecha = new Date(item.fecha);
const ahora = new Date();
return (ahora - fecha) < dias * 86400000;
}
function faviconSrc(dominio) {
return `https://www.google.com/s2/favicons?domain=${dominio}&sz=32`;
}
Estas funciones se pueden testear con entradas y salidas directas, sin preparar el DOM.
4. Dependency Inversion — Filtros reactivos
Los filtros dejan de buscar el estado en variables globales y lo reciben del store. Cuando el store cambia, los filtros se re-ejecutan automáticamente:
function aplicarFiltrosNoticias(state) {
const { category, source, channel, search } = state;
document.querySelectorAll('.news-item').forEach(item => {
const coincideCategoria = category === 'all' || item.dataset.cat === category;
const coincideFuente = source === 'all' || item.dataset.source === source;
const coincideCanal = !channel || item.dataset.channel === channel;
const coincideBusqueda = !search ||
item.querySelector('.news-title')?.textContent.toLowerCase().includes(search);
item.style.display = (coincideCategoria && coincideFuente && coincideCanal && coincideBusqueda) ? '' : 'none';
});
}
// Suscripción reactiva
store.on(aplicarFiltrosNoticias);
store.on(aplicarFiltrosVideos);
5. Interface Segregation — Chips duales
Las fuentes que tienen YouTube y web (MoureDev, Midudev, Carlos Azaustre, Xataka) aparecen en los filtros de ambas secciones. En la arquitectura anterior, esto se resolvía con lógica duplicada en cada función de renderizado. Con el store, un solo chip actualiza el estado y ambos filtros reaccionan:
function renderCanalChips(container, canales) {
renderChips(container, canales, {
showIcon: true,
onToggle: (canal, activo) => {
store.set({ channel: activo ? canal : '' });
}
});
}
Los pasos de la migración
Paso 1: Identificar el epicentro del caos
El primer paso fue mapear dependencias. Hice un grep de cada variable global para ver dónde se leía y dónde se escribía:
currentCategory → se escribía en 4 sitios, se leía en 6
currentSource → se escribía en 3 sitios, se leía en 5
searchQuery → se escribía en 2 sitios, se leía en 4
Cada variable tenía al menos 5 puntos de acoplamiento. El patrón era claro: las variables globales eran el mecanismo de comunicación entre funciones, y eso las hacía frágiles.
Paso 2: Extraer el Store sin romper nada
El store se implementó primero como un módulo aislado. No se tocó ningún código existente: simplemente se creó el store y se verificó que existía y funcionaba con tests manuales en consola:
// Verificación manual
store.set({ category: 'devops' });
console.log(store.get()); // { category: 'devops', source: 'all', ... }
Paso 3: Migrar un filtro a la vez
No se reescribió todo de golpe. Se empezó por el filtro de noticias, que era el más simple:
- Se registró
store.on(aplicarFiltrosNoticias) - Se verificó que el filtro de noticias seguía funcionando igual
- Se migró el chip de categoría para que escribiera al store en lugar de modificar
currentCategory - Se verificó de nuevo
Este patrón se repitió para cada filtro: fuente, canal, búsqueda, vídeos, GitHub.
Paso 4: Eliminar las funciones duplicadas de chips
Con todos los filtros ya conectados al store, las 8 funciones de renderizado de chips se reemplazaron por renderChips() y renderCanalChips(). Cada reemplazo seguía el mismo proceso:
- Se escribía la llamada a
renderChips()con los datos de la función original - Se verificaba que el resultado visual era idéntico
- Se eliminaba la función original
- Se ejecutaban los tests
Paso 5: Separar helpers puros
Las funciones que mezclaban lógica y efectos secundarios se separaron:
limpiarFuente()quedó como función pura- El acceso al DOM que tenía se movió al contexto que la llamaba
- Se verificó que cada helper podía testearse con una entrada y una salida, sin mocks
Paso 6: Auditoría de fugas de memoria
Se registraron todos los addEventListener del archivo y se verificó que cada uno tenía su removeEventListener correspondiente o su unsubscribe del store. Se encontraron 3 listeners que nunca se eliminaban y se corrigieron.
Paso 7: Tests y validación
La validación fue en tres capas:
- Tests unitarios de helpers:
limpiarFuente(),tipoFuente(),itemEnSemana(),faviconSrc()— funciones puras, fáciles de testear - Tests de integración del store: verificar que
set()dispara los listeners y queunsubscribe()elimina correctamente el handler - Tests visuales: verificar que los filtros del dashboard producen los mismos resultados que antes del refactor
Qué cambió en la arquitectura
| Aspecto | Antes | Después |
|---|---|---|
| Variables de estado | 10+ globales | 1 store centralizado |
| Gestión de eventos | onTipoFuenteChange() monolítica (~80 líneas) | Filtros reactivos suscritos al store |
| Renderizado de chips | 8 funciones duplicadas | 2 genéricas (renderChips, renderCanalChips) |
| Helpers | Con efectos secundarios (DOM, fetch) | Puros (solo transforman datos) |
| Memoria | Listeners sin eliminar | unsubscribe en cada on() |
| Testeabilidad | Requería DOM completo | Helpers testeados con entradas/salidas |
| Añadir un filtro nuevo | Modificar la función monolítica | Crear una función de filtro + suscribirse al store |
Lo que aprendí
-
El Store Observable es el patrón más subestimado del JavaScript vanilla. No necesitas Redux, Zustand ni ningún framework para tener estado reactivo. Un objeto con
get(),set()yon()es suficiente para dashboards con filtros. -
La migración incremental es clave. No se reescribió todo de golpe. Se migró un filtro a la vez, verificando que cada paso seguía funcionando. Esto reduce el riesgo a cero.
-
Los helpers puros se escriben solos una vez que separas datos de DOM. El problema no es la lógica, es que la lógica esté mezclada con accesos al DOM. Separarlas es el cambio más valioso.
-
Las fugas de memoria son un bug silencioso. No falla nada, pero la app se va ralentizando. El patrón
unsubscribelas elimina por diseño. -
El refactor SOLID no es academicismo: es pragmatismo. El código resultante es más corto, más fácil de testear y más seguro de modificar. Eso se traduce en velocidad de desarrollo.
