Saltar al contenido
En esta página

Los principios SOLID en el frontend

7 min de lectura

Las cinco letras de SOLID, una por una, contadas por el problema que cada una resuelve en un frontend real: qué se rompe exactamente cuando el principio no está.

¿Alguna vez has cambiado una línea de un componente y has acabado tocando otros cuatro archivos que no tenían nada que ver con lo que te pidieron? Hoy vamos a recorrer las cinco letras de SOLID en su orden, una por sección, pero al revés de como suelen contarse: primero te enseño el código que duele, y el nombre del principio llega al final, cuando ya has visto qué se rompe sin él. El ejemplo va a ser Nébula, una tienda online inventada. Vamos despacio: cada letra es un problema concreto, no una definición.

Cinco reglas sobre dónde poner las costuras

SOLID es un acrónimo que agrupa cinco principios de diseño formulados alrededor del año 2000 por Robert C. Martin, pensados para código orientado a objetos: clases, herencia e interfaces. Un frontend moderno tiene pocas clases —tiene funciones, componentes y módulos—, así que la traducción no es literal y conviene ser preciso con qué es lo que los cinco regulan de verdad.

Regulan dónde va la costura: el punto por el que el código se separa en dos partes que a partir de ahí cambian por separado. El enemigo común de los cinco es el acoplamiento: dos piezas están acopladas cuando cambiar una te obliga a cambiar la otra. No se mide en líneas ni en carpetas, sino en cuántos archivos abres para hacer un cambio que conceptualmente era uno solo.

Cada letra responde a una pregunta distinta sobre esa costura:

LetraPrincipioLa pregunta que responde
SResponsabilidad única¿Por qué razones cambia este archivo?
OAbierto/cerrado¿Por dónde entra un caso nuevo?
LSustitución de Liskov¿Puedo cambiar esta pieza por otra del mismo tipo?
ISegregación de interfaces¿Cuánto tengo que saber para usar esto?
DInversión de dependencias¿Quién nombra a quién?

Ninguna se aplica preventivamente. Las cinco son respuestas a un síntoma que aparece antes.

El archivo que cambian tres equipos distintos

En Nébula hay un componente OrderTable que pinta la tabla de pedidos. Dentro hace cuatro cosas: pide los pedidos al servidor, los filtra por estado, formatea los importes según las reglas de facturación y devuelve el JSX. Mientras funcione, nada de eso parece un problema; el problema aparece en quién pide los cambios:

  1. El equipo de backend cambia el endpoint. Hay que abrir OrderTable.
  2. Diseño cambia el orden de las columnas. Hay que abrir OrderTable.
  3. Facturación cambia cómo se redondea el IVA. Hay que abrir OrderTable.

Tres grupos de personas que no hablan entre sí editando el mismo archivo la misma semana: conflictos de merge en cada rama, y un test del redondeo que no puedes escribir sin levantar un servidor falso, porque la función que redondea vive dentro de un componente que hace fetch.

La costura que falta separa esas tres razones de cambio:

components/orders/order-table.tsx
// Un hook para los datos: cambia cuando cambia el servidor.
const { orders } = useOrders();
 
// Una función pura para el dinero: cambia cuando cambia facturación.
const total = formatAmount(order.total);
 
// Y el componente, que solo pinta: cambia cuando cambia diseño.
return <table>{/* … */}</table>;

Esto es el principio de responsabilidad única, y su formulación popular —"cada función hace una sola cosa"— es la menos útil de las posibles. La original habla de tener una sola razón para cambiar, y una razón es un grupo de personas que pide cambios. Un archivo que orquesta diez llamadas del checkout tiene una sola responsabilidad si todas cambian por el mismo motivo; el OrderTable de arriba tenía tres aunque cupiera en una pantalla.

Cargando la animación…

El switch que hay que abrir cada vez que llega un caso nuevo

Nébula pinta el estado de cada pedido con una etiqueta de color, y el componente resuelve el color con un switch de cuatro ramas: pendiente, pagado, enviado, entregado.

Entonces producto añade los reembolsos. Para pintar el quinto estado hay que abrir el archivo, añadir una rama y republicar un componente que llevaba meses funcionando. Ese es el coste real: cada caso nuevo te obliga a editar código probado y en producción, y a arriesgar los cuatro casos que ya iban bien por un fallo en el quinto.

La alternativa es que añadir un estado sea añadir un dato, no una rama:

components/orders/status-badge.tsx
const badgeByStatus: Record<OrderStatus, BadgeStyle> = {
  pending: { label: "Pendiente", tone: "neutral" },
  paid: { label: "Pagado", tone: "accent" },
  shipped: { label: "Enviado", tone: "accent" },
  delivered: { label: "Entregado", tone: "muted" },
  refunded: { label: "Reembolsado", tone: "warning" }, 
};

El componente que lee ese mapa no se toca nunca más. Esto es el principio abierto/cerrado: abierto a extensión —caben casos nuevos— y cerrado a modificación —el código que ya funciona no se edita para meterlos.

La pieza que dice ser un botón y no lo es

En Nébula alguien crea IconButton para tener botones con icono. Por dentro, en vez de un <button>, renderiza un <div> con un onClick encima. Se ve idéntico.

Y entonces empiezan los fallos raros. Dentro de un formulario no envía nada, porque solo un <button> de verdad tiene el type="submit" que dispara el envío. No se llega a él con el tabulador, porque un <div> no está en el orden de foco. Su prop disabled lo pinta en gris, pero el onClick sigue disparando. Quien lo usó no leyó su código: leyó su nombre y asumió el contrato de un botón.

Eso es lo que rompe el principio de sustitución de Liskov: si una pieza se presenta como algo, quien la usa tiene que poder cambiar una por otra sin leer por dentro ni añadir excepciones. Y el contrato de un botón en el navegador no son solo sus props: incluye su comportamiento con el teclado, con los formularios y con los lectores de pantalla. La regla práctica es envolver el elemento nativo y reenviarle el contrato en vez de reinventarlo:

components/ui/icon-button.tsx
type IconButtonProps = ComponentProps<"button"> & { icon: ReactNode };
 
export function IconButton({ icon, ...props }: IconButtonProps) {
  return <button {...props}>{icon}</button>;
}

TypeScript comprueba la forma de las props, nunca el comportamiento: que IconButton acepte onClick no dice nada sobre si funciona con el teclado. Eso solo lo garantiza apoyarse en el elemento que ya lo hace.

Veintidós props para pintar tres filas

El equipo de plataforma de Nébula construye un DataTable común que con el tiempo acumula veintidós props: paginación en servidor, exportación a CSV, selección múltiple, virtualización, edición en línea. Cada una la pidió un equipo distinto y todas están justificadas.

El coste lo paga quien solo quiere pintar tres filas: hay que leerse las veintidós props para decidir cuáles no aplican, y el tipo obliga a cada consumidor a conocer conceptos —serverPagination, onExport— que en su pantalla no existen. Cuando facturación añade la prop veintitrés, el archivo que cambia es el que importan las quince pantallas del producto.

El principio de segregación de interfaces dice justamente eso: nadie debería depender de partes de una interfaz que no usa. En el frontend la interfaz de un componente son sus props, y la salida no es partirlo en quince, sino exponer piezas pequeñas que se componen.

<Table>
  <Table.Header columns={columns} />
  <Table.Body rows={rows} />
</Table>

Quien necesita exportar a CSV compone además la pieza que exporta; quien no, nunca se entera de que existe.

El componente que sabe de dónde salen los datos

Última letra, y la que más cambia la forma del proyecto. OrderList importa el cliente de base de datos directamente:

components/orders/order-list.tsx
import { supabase } from "@/lib/supabase";
 
const { data } = await supabase.from("orders").select("*");

La pantalla de pedidos es de lo más estable del producto: lleva dos años pintando pedidos y seguirá haciéndolo. El cliente de base de datos es de lo más volátil: se cambia de proveedor, se mete una caché, se pasa por una API propia. Con ese import, lo estable nombra a lo volátil, y el día del cambio hay que abrir todas las pantallas. De paso, testear OrderList obliga a interceptar un módulo de terceros.

El principio de inversión de dependencias invierte quién nombra a quién. La pantalla declara qué necesita, en sus propios términos, y el detalle se adapta a ese contrato:

lib/orders/repository.ts
export type OrderRepository = {
  list(): Promise<Order[]>;
};

OrderList recibe algo que cumpla OrderRepository —por props, por contexto o por un parámetro— y deja de saber que Supabase existe. La flecha se ha dado la vuelta: antes el componente dependía del proveedor, ahora el proveedor depende de un contrato que define el componente. Lo importante no es haber escrito un tipo, sino de quién es: lo define quien lo consume, no quien lo implementa.

Cargando la animación…

Las cinco letras de un vistazo

LetraSíntoma en el códigoDónde pones la costuraQué dejas de tocar
STres equipos editan el mismo archivoEntre datos, reglas y pintadoEl componente, cuando cambia el servidor
OUn switch que crece con cada casoEntre la tabla de casos y el que la leeEl código probado, al añadir un caso
LUn envoltorio que rompe a quien lo usaEn el contrato del elemento nativoLos formularios y el teclado
IVeintidós props para tres filasEntre las piezas que se componenLas pantallas que no usan la prop nueva
DUn import de un proveedor concretoEn un contrato que define el consumidorLas pantallas, al cambiar de proveedor

La columna que importa es la primera. SOLID no es una lista de cosas que hacer antes de escribir código: es un diccionario que traduce un dolor que ya estás sintiendo al nombre de la costura que te falta. Sin ninguno de esos síntomas, aplicar los cinco principios solo te deja indirección.

Dos minutos en tu propio proyecto

Abre el componente más largo que tengas y responde tres preguntas sin cambiar nada. ¿Cuántos grupos distintos de personas pueden pedir un cambio en ese archivo? ¿Qué tendrías que editar para añadir un caso más al comportamiento que más crece? ¿Cuántos de sus import nombran a un proveedor concreto en vez de a un contrato tuyo?

La respuesta a la primera te dice si te falta la S, la segunda si te falta la O y la tercera si te falta la D. Empieza por la que te haya salido peor, y solo por esa.