Evita el lado oscuro del código: SOLID y Clean Code con Typescript
“Difícil de mantener tu código será, si muchas responsabilidades juntas tu dejas. Separar debes, si fuerte en la Fuerza quieres ser.”
— Maestro Yoda, Gran Maestro del Clean Code
Capítulo 1: El Consejo Jedi del Clean Code
No importa si llevas años programando en una nave capital, en una pequeña nave de carga o recién acabas de abordar el universo del desarrollo: los principios SOLID te los vas a topar por todas partes. Libros, blogs, videos, bootcamps… a veces parece que están en el aire, como esa misteriosa “fuerza” que une a toda la galaxia.
Sé que estas reglas muchas veces suenan abstractas, repetidas o demasiado alejadas de lo que uno enfrenta en la realidad de una empresa. Y lo entiendo: yo también alguna vez me perdí entre ejemplos artificiales o teorías que nunca aterrizaban.
Por eso, no solo me apoyo en las definiciones formales de Clean Code (Robert C. Martin) y Clean JavaScript (Miguel A. Gómez), sino que intento ir un poco más allá, mostrando ejemplos y problemas que me han tocado resolver en mi día a día.
El objetivo es que los principios SOLID dejen de ser “el código de los Jedi” y se conviertan en parte de tu rutina profesional, con explicaciones claras y ejemplos aterrizados, sin necesidad de memorizar el árbol genealógico Skywalker. ¡Que el refactor te acompañe!
Capítulo 2: Los cinco caminos de SOLID
2.1 S – Single Responsibility Principle (SRP)
"Que tu clase o función tenga un único propósito, joven padawan. Si tu función gestiona las flotas, no debería estar dando la orden de disparar."
— Obi-Wan Kenobi, Arquitecto Jedi
Aunque siento que Robert C. Martin describió el SRP pensando originalmente en clases, este principio es igual de valioso para cualquier unidad de código: funciones, módulos e incluso servicios completos. Complementando, Miguel A. Gómez en Clean JavaScript, nos explica qué entendemos por "Responsabilidad".
"El principio de responsabilidad única no se basa en crear clases con un solo método, sino en diseñar componentes que solo estén expuestos a una fuente de cambio. [...] responsabilidad hace referencia a aquellos actores (fuentes de cambio) que podrían reclamar diferentes modificaciones en un determinado módulo dependiendo de su rol en el negocio."
— Miguel A. Gómez, Clean JavaScript
Te comparto un caso donde pagué el precio de ignorar el SRP. La situación era simple: cuando un cliente respondía un formulario, había que filtrar ciertos productos y luego enviar un resumen en HTML de esos productos por correo. Por ir rápido (y para que mentir, con presión de "más arriba" en tiempos), terminé con algo como esto:
export async function sendHtml(
products: Product[],
purchaseOrder: string,
users: User[]
) {
const promotion = products
.map(product => product.detail)
.filter(product => Boolean(product.isInPromo))
.map(product => ({
price: product.price,
iva: product.price * 0.19,
total: product.price + product.price * 0.19,
units: product.stock,
name: product.name,
slug: slug(product.name)
})) as Promotion[];
const html = buildPromoHtml(promotion, purchaseOrder);
const promises = users
.map(user => user.email)
.map(email => sendEmail(email, html));
return await Promise.all(promises);
}
function buildPromoHtml(
promotion: Promotion[],
purchaseOrder: string
) {
/* ... */
}
async function sendEmail(email: string, html: string) {
/* ... */
}
¿Cumple el requerimiento? Sí. ¿Es mantenible? No. La función filtra productos, calcula promociones, arma el HTML y envía correos, todo en un solo bloque.
Ya sabes como es esto: Por más bien que funcione, al poco tiempo llega el cliente solicitando un control de cambios que para ellos es "bastante simple":
"Ahora, a los usuarios enterprise mándales el mismo correo, pero aplica un 20% de descuento solo si llevan más de 40 unidades por producto."Y con esto inicia el ciclo sin fin, aparecen más condiciones y más reglas de negocio por cliente... tu función empieza a crecer y enredarse. ¿Duplico código? ¿Copio y pego cambiando detalles a mano? ¿Y si mañana hay un bug en la lógica de descuento, cuántos lugares tengo que actualizar?
Este es el clásico síntoma de romper el SRP: Una clase (o función en este caso) hace demasiadas cosas a la vez. El menor cambio en el formato del correo, en la lógica de promociones o en el envío de emails puede forzarte a reescribir toda la función.
¿Cómo se resuelve esto? Aplicando la famosa frase "divide y vencerás", o en palabras más informáticas: "dividiendo responsabilidades"
- Una función filtra.
- Otra calcula promociones.
- Otra arma el HTML.
- Otra envía los correos.
Así, cada parte tiene una sola razón para cambiar. Si cambia la lógica de promoción, solo modificas ese bloque. Si cambian el HTML, solo tocas el generador. El código se vuelve más fácil de mantener, testear y evolucionar.
2.2 O – Open/Closed Principle (OCP)
"El pasado es inmutable, pero tu código puede adaptarse al futuro."
— Qui-Gon Jinn, Filósofo de la Legibilidad del Código
A simple vista, esta frase da la impresión que carece de sentido. ¿Para qué voy a escribir código que no debería editar en el futuro? ¿No se supone que siempre estamos cambiando y mejorando nuestros sistemas? ¿Por qué habría de preocuparme tanto? ¿Por qué molestarse en protegerlo de cambios?
La verdadera razón detrás es el efecto dominó que producen los cambios en el código. Imagina que tienes una función que hoy resuelve tu problema, pero pronto empieza a ser usada en más partes del sistema, la documentas, la testean, y de a poco se transforma en uno de esos pequeños pilares de la aplicación. Si cada vez que aparece un nuevo requerimiento tienes que abrir y modificar esa función, corres el riesgo de romper algo que antes funcionaba perfecto.
¿De verdad quieres ser la persona que toque esa función legendaria que dejó un fundador, esa que nadie sabe exactamente para qué sirve y cuyo autor ya ni trabaja en la empresa? Un descuido y tiras abajo medio sistema. Mejor evitar ese susto, ¿no?
Este principio me toca directamente porque lo viví en mi eterna tesis. Hay un módulo encargado de hacer scraping, y necesitaba obtener datos desde distintas fuentes: APIs en JSON, páginas HTML, archivos CSV, etc. El problema era que no había ningún estándar y, cada vez que quería añadir una fuente nueva, tenía que meter más lógica al mismo if, obligando a testear toda la función de nuevo.
function getFetchData(url: string, method: string) {
if (method === "download") return downloadMethod(url);
else if (method === "json") return jsonMethod(url);
else if (method === "puppeteer") return puppeteerMethod(url);
throw new Error("Método no encontrado");
}
¿El código funcionaba? Sí, hasta que había que agregar un nuevo método de obtención de datos y ahí venía el dolor: abrir la función, editarla, rezar para no romper nada y volver a probar todo desde cero. Un “efecto dominó” en toda regla. ¿Suena conocido?
A diferencia del SRP, este principio se entiende mucho mejor si vemos un antes y un después en el código.
[...] existen varias técnicas para aplicarlo [OCP], pero todas ellas dependen del contexto en el que estemos. Una de estas técnicas podría ser utilizar un mecanismos de extensión, como la herencia o la composición, para utilizar esas clases a la vez que modificamos su comportamiento
— Miguel A. Gómez, Clean JavaScript
La solución fue aplicar dos patrones de diseño clásicos: Adapter y Factory. En este caso, una versión básica del Factory Pattern fue más que suficiente para resolver el problema; existen implementaciones mucho más sofisticadas, pero para lo que necesitaba, esto fue ideal. ¿Qué son y para qué sirven? En este artículo me enfoco en SOLID, así que no me voy a extender en detalles; lo importante es ver cómo los usé y qué resolvieron. De todas formas, te dejaré una excelente página en las referencias donde podrás aprender a detalle los patrones de diseño.
En palabras simples: imagina que todo tu sistema espera que cualquier clase que obtenga datos tenga un método fetch(url). Pero de repente necesitas traer datos de una fuente que solo ofrece un método download(url), o quizás usa otra librería que hace las cosas diferente. El Adapter Pattern te permite crear una clase adaptadora que envuelve esa fuente y le “traduce” su método download() a fetch(). Así, puedes seguir usando el mismo contrato (FetchAdapter) en todo tu sistema, sin tener que modificar ni la fuente original ni el resto del código.
Para lograr esto, defino una clase abstracta (aunque también podrías usar una interfaz) que especifica el método que deben implementar todas las clases que extiendan de ella:
export abstract class FetchAdapter {
abstract fetch: (url: string) => Promise<string>;
}De este modo, cualquier clase que herede de FetchAdapter estará obligada a implementar una función llamada fetch, que recibe una URL (url: string) y retorna una promesa con el resultado en formato string (Promise<string>). Así puedo crear fácilmente nuevas clases adaptadoras para cada fuente de datos, todas cumpliendo el mismo contrato:
export class DownloadAdapter implements FetchAdapter {
async fetch(url: string): Promise<string> {
/* ... */
}
}
export class JsonFetchAdapter implements FetchAdapter {
async fetch(url: string): Promise<string> {
/* ... */
}
}
export class PuppeteerAdapter implements FetchAdapter {
async fetch(url: string): Promise<string> {
/* ... */
}
}
Ahora puedes agregar nuevas fuentes de datos simplemente creando una clase nueva que extienda de FetchAdapter y listo, sin tener que modificar el código existente.
Pero con esto todavía no resuelvo el problema de fondo: ¿cómo decido en tiempo de ejecución qué adapter utilizar, sin caer en el clásico if gigante? Aquí es donde entra en acción el Factory Pattern.
En otras palabras, es como tener una “fábrica” a la que simplemente le preguntas: “Necesito un adapter para este caso, ¿me lo puedes dar?” y listo, te entrega la clase adecuada sin dolores de cabeza.
export class FetchAdapterFactory {
private static registry: Record<string, new () => FetchAdapter> = {};
static register(type: string, adapter: new () => FetchAdapter) {
this.registry[type] = adapter;
}
static get(type: string): FetchAdapter {
const AdapterClass = this.registry[type];
if (!AdapterClass) throw new Error("Adapter no registrado");
return new AdapterClass();
}
}
Ahora que tenemos el factory listo, solo queda registrar tus adaptadores. Lo que antes era un montón de if anidados, ahora se reduce a algo tan elegante como esto:
FetchAdapterFactory.register("download", DownloadAdapter);
FetchAdapterFactory.register("json", JsonFetchAdapter);
FetchAdapterFactory.register("puppeteer", PuppeteerAdapter);Y para obtener la data se haría de esta forma:
const adapter = FetchAdapterFactory.get(type);
const data = await adapter.fetch(url);Mucho más limpio y legible, ¿verdad? Bueno, a estas alturas sé lo que estás pensando: “¿De verdad todo este esfuerzo solo para evitar un if que podría escribir en menos 5 minutos?”
Y aquí es donde entra el verdadero aprendizaje: aplicar estos principios no es una obligación absoluta. Todo depende del contexto. ¿Tiene sentido complicar tanto el código y agregar todas estas líneas solo para manejar tres casos? Probablemente no. Antes de lanzarte a implementar patrones y abstracciones, hay que considerar si el proyecto realmente va a escalar, cuán probable es que esa función cambie en el futuro y si tu equipo va a entender lo que estás haciendo.
Por eso, recuerda: estos principios no son un mantra sagrado que debas seguir a ciegas, sino herramientas para usar con criterio. El arte está en saber cuándo aplicar cada uno… y cuándo dejarlo pasar.
2.3 L – Liskov Substitution Principle (LSP)
"Pi-pi-piuu, brrrr-bip, wiiiwii!" (Si tu interfaz promete que un droide puede reparar puertas, más vale que cualquier droide que implemente esa interfaz realmente pueda hacerlo)
— R2-D2, Astromecánico Lead en Arduino
Difícil olvidar la vez que rompí el LSP. Pocas horas de sueño, un deadline imposible y cambios de requisitos que llegaban cada hora: un cóctel clásico para cometer errores. Probablemente mi código siga rondando por ahí, escondido en alguna función de un olvidado proyecto legacy.
Todo comenzó cuando el cliente pidió que los archivos solo podían ser enviados en formato base64, y las fuentes desde donde había que obtenerlos eran variadas. Así que, fiel al instinto de todo dev con presiones y poco tiempo, creé una interfaz común para los archivos sin pensarlo:
interface CustomFile {
get(id: string): Promise<string>
}En ese momento, la lógica era sencilla: los archivos venían desde una URL directa, se descargaban, se guardaban en /tmp, se convertían a base64, se enviaban y listo:
class UrlFile implements CustomFile {
async get(id: string): Promise<string> {
const response = await fetchFileById(id);
const buffer = await response.arrayBuffer();
// Guardar en /tmp...
return Buffer.from(buffer).toString('base64');
}
}Hasta aquí, todo bien, pero nunca hay que subestimar el poder de las "implementaciones especiales" del cliente. En una "segunda etapa" (quizá unos días de diferencia), el cliente necesitaba archivos desde otra fuente. Sin problemas, pensé: la interfaz ya estaba implementada, no era necesario tocar nada... ¿cierto?
No tan rápido. Al agregar esta nueva fuente de datos, el sistema comenzó a fallar de formas misteriosas y sin seguir un patrón en común. Después de mucho debug, descubrí el motivo: en esta nueva fuente de datos, cuando el cliente subía un archivo y nos enviaba el id, ese archivo podía no estar listo de inmediato. Si intentábamos acceder mientras seguía procesándose, recibíamos algo así:
{
id: "6892c2058efcd900e9e3f060",
url: "http...",
status: "pending"
}Hasta este punto ya sabemos por qué se está rompiendo el LSP: Los objetos de un programa (CustomFile) deberían ser reemplazables por instancias de sus subtipos (UrlFile y ClientFile) sin alterar el correcto funcionamiento del programa. El error es bastante notorio, ya que UrlFile retorna un base64 mientras que ClientFile retorna un objeto.
Pese a esto, logré "parcharlo": Como buen developer pragmático, intenté mantener la interfaz; pero aquí todo tiene un precio, y el costo a pagar fue forzar el contrato para devolver algo “parecido”, cueste lo que cueste:
class ClientFile implements CustomFile {
async get(id: string): Promise<string> {
while (true) {
const res = await api.getFileStatus(id);
if (res.status === 'ready') {
const fileResponse = await fetch(res.url);
const buffer = await fileResponse.arrayBuffer();
return Buffer.from(buffer).toString('base64');
}
await sleepInSeconds(4);
}
}
}¿Y por qué esto es un problema real? Porque el contrato de la interfaz prometía que siempre recibirías un string con el archivo en base64, pero esa promesa ya no era cierta. Lo que hice fue “parchar” la interfaz para que la implementación ClientFile intentara cumplirla, bloqueando el código hasta que el archivo estuviera disponible. Técnicamente, el método seguía devolviendo un string, pero en la práctica forzaba al sistema a esperar (quizás mucho tiempo). Rompiendo cualquier expectativa razonable de tiempos de respuesta, manejo de errores o control de flujo en el resto del programa.
Si te preguntas cómo lo solucioné. Lo siento, pero en el mundo del desarrollo no existen soluciones perfectas, el apuro es enemigo del buen diseño. Todavía debe estar por ahí ese código funcionando sin que nadie se entere de tal atrocidad que logró salvar un desarrollo en tiempo record.
¿Cómo debería haberlo resuelto? Una solución hubiera sido separar responsabilidades y crear interfaces distintas: una para proveedores síncronos e inmediatos, y otra para los asíncronos/lentos.
2.4 I – Interface Segregation Principle (ISP)
“No sobrecargues un stormtrooper con acciones que no haría. Que cada quien haga lo suyo, como R2 arreglando cosas y yo pilotando el Halcón.
— Han Solo, Developer Pragmático
Afortunadamente, este principio es más sencillo de entender en teoría, pero es muy fácil romperlo en la práctica sin darnos cuenta.
Para este caso, tomaré como inspiración una implementación que tuve que realizar hace ya un tiempo, solo que alteré levemente el requisito para simplificar este artículo.
El cliente nos entregó un endpoint de Azure Blob Storage con esta estructura: PUT|GET - /endpoint-cliente/nombre-archivo.json donde nombre-archivo.json es como el "ID" de la "base de datos" y el body es el contenido. Si haces un PUT, el archivo se crea (o se sobreescribe por completo), y si haces un GET te devuelve el texto completo
El reto era construir un CRUD solo con ese endpoint. Pero dejar esa lógica expuesta en el código sería un dolor de cabeza para cualquier dev futuro.
Por si fuera poco, el cliente podría pedir más adelante que parte de la información se escriba en otra base de datos o en otro endpoint, y para añadir, yo también necesitaba probar en local escribiendo archivos en /tmp. Así que la mejor opción era utilizar el Repository Pattern.
Lo primero fue definir una interfaz común que todos los repositories deben seguir. Así, sin importar la fuente de datos, todos tendrán al menos los métodos create (para guardar) y read (para leer):
export interface Repository {
create(data: Data): Promise<Data>;
read(id: string): Promise<Data>;
}Luego, implementé dos repositorios: uno para manipular archivos en Azure Blob Storage y otro para escribir/leer en local (ideal para pruebas):
export class LocalRepository implements Repository {
async create(data: Data) {
// Aquí va la lógica para escribir en /tmp un fichero JSON
}
async read(id: string) {
// Aquí va la lógica para leer el fichero JSON en /tmp
}
}
export class AzureRepository implements Repository {
constructor(private readonly configFile: AzureConfig) {}
async create(data: Data) {
// Aquí va la llamada de red PUT a /endpoint-cliente/nombre-archivo.json
}
async read(id: string) {
// Aquí va la llamada de red GET a /endpoint-cliente/nombre-archivo.json
}
}
Con esto, hemos solucionado con creces la solicitud requerida por el cliente, pero supón que el cliente ahora te pide integrar una nueva base de datos en PostgreSQL de una vista materializada, esto significa que tendrás acceso de solo-lectura (read-only).
Así que como buen desarrollador, comienzas a crear una nueva clase que implemente Repository.
export class PostgresqlRepository implements Repository {
async create(data: Data) { /* ¿? */ }
async read(id: string) {
// Aquí va la lógica para leer desde Postgresql
}
}Aquí está el problema: PostgresqlRepository está obligado a implementar create, pero no tiene por qué hacerlo, ya que el sistema solo da acceso de lectura. La salida fácil sería que el método lance un error tipo "Método create no soportado". Pero ahora tendrás declarado un método que nunca vas a usar y que puede fallar en tiempo de ejecución.
Como verás, se rompe el ISP porque ningún cliente (PostgresqlRepository) debe depender de interfaces (Repository) que no utilizan.
¿Cómo se resuelve esto? No hay una fórmula universal, pero aquí sí aplica usar interfaces más específicas. Por ejemplo, podrías tener una interfaz ReadableRepository y otra WritableRepository, y que cada repositorio implemente sólo las capacidades que puede cumplir. O, si el caso es muy particular, podrías reestructurar el diseño para adaptarlo mejor al problema real, evitando exponer métodos que no tienen sentido para ciertas fuentes de datos.
Si quieres más detalles sobre este caso, te invito a leerlo en la siguiente publicación:

2.5 D – Dependency Inversion Principle (DIP)
“Solo la disciplina y el apego a los principios mantienen el equilibrio. Los módulos de alto nivel deben depender de abstracciones, no de detalles, así como un Jedi confía en la Fuerza.”
— Mace Windu, Gran Maestro del Clean Code
Este principio es tan importante que existen frameworks completos en Node (y en muchos otros lenguajes) que se que se construyen en torno a él, tales como NestJs o Angular.
Como siempre, traigo un caso real, aunque reconozco que aquí caí en la sobreingeniería para un problema demasiado simple. En el mundo del desarrollo no siempre hay victorias épicas.
El problema: Un cliente me pidió que el título de cada producto incluyera un “código legible” para facilitar su búsqueda. Como también trabajo de consultor, pude deducir que realmente lo que él buscaba era generar un slug para cada nombre del producto, es decir, una cadena simple y amigable. Por ejemplo: "Café Orgánico 250g" → cafe_organico_250g.
Esto normalmente implica reemplazar espacios por guiones bajos, eliminar tildes, y pasar a minúsculas. La solución más directa para no complicarme la vida era usar una librería como slug de Node.js.
Sin embargo, opté por no acoplar mi código directamente a la librería. ¿Por qué? Porque (según yo) si en el futuro el cliente quería cambiar la lógica del slug, o si necesitábamos una variante especial, modificar el código base sería un dolor. Así que apliqué DIP… y, de paso, el Bridge Pattern.
Para esto, comenzamos con lo que ya hemos visto en todo este artículo: Una clase abstracta o una interfaz común:
export interface Slugify {
toSlug(value: string): string;
}De este modo, cualquier lógica de negocio del código conoce solo el contrato toSlug(value), pero nunca la implementación concreta. Así, si el día de mañana cambio la lógica, el resto del código ni se entera.
La primera implementación usando la librería slug:
import slug from 'slug';
export class NodeSlug implements Slugify {
constructor(options: Record<string, unknown> = {}) {}
toSlug(value: string): string {
return slug(value, options)
}
}Luego, en el servicio de negocio, aplico inyección de dependencias:
class ProductService {
constructor (private readonly slugify: Slugify) {}
createProduct(product: Product) {
const code = this.slugify.toSlug(product.name);
// resto de lógica
}
}Ahora, en alguna parte de la app, al momento de instanciar ProductService debería pasar por parámetro cuál es la implementación de Slugify que quiero utilizar:
const nodeSlug = new NodeSlug();
const productService = new ProductService(nodeSlug);
await productService.createProduct(product);Este patrón de diseño es especial para poder cambiar tipos de implementaciones, en especial si son de librerías externas. Por ejemplo, si mañana el cliente pide un formato distinto, solo cambio la implementación:
export class SimpleSlug implements Slugify {
toSlug(value: string): string {
return value
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
}
Y para usarlo, bastaría con dejar de utilizar NodeSlug y reemplazarlo por SimpleSlug via inyección de dependencias.
const simpleSlug = new SimpleSlug();
const productService = new ProductService(simpleSlug);
await productService.createProduct(product);¿Fue sobreingeniería para este caso? Definitivamente sí. ¿Cuántas veces un cliente pide cambiar el formato del slug? Muy pocas (por no decir nunca). Pero al menos el proyecto quedó preparado para requisitos futuros sin dolor; aunque siendo sincero, eso tal vez nunca pase.
Capítulo 3: De Padawan a Jedi del SOLID
El Imperio cayó por falta de revisiones de seguridad, pero tu código no tiene por qué seguir el mismo destino. Cuando estructuras tus servicios, integraciones y sistemas aplicando los principios SOLID, tu aplicación se vuelve tan resiliente como el Halcón Milenario escapando de un destructor.
Eso sí, toma en cuenta esto: estas habilidades no se desarrollan de la noche a la mañana. Es fácil ver en internet a desarrolladores tomando decisiones impecables y pensar que, si simplemente los imitamos, obtendremos automáticamente un buen diseño. Pero la realidad es que este mundo del desarrollo está lleno de malas decisiones, tomadas muchas veces por factores externos como la presión de tiempo, cambios constantes o simplemente por desconocimiento (no podemos saber todo de todo en esta vida).
El buen diseño es fruto de la práctica constante y, sobre todo, de la capacidad de reconocer y aprender de nuestros propios errores. No te desanimes si tropiezas: cada error es una oportunidad para mejorar y fortalecer tu código. Eso es justamente lo que he intentado transmitir en este pequeño artículo.
¿Y tú? ¿Cuál de estos principios has visto más veces vulnerado en tu galaxia de proyectos? Déjame tu experiencia en los comentarios y sigamos creciendo juntos en el camino del Clean Code.
Referencias:





Member discussion