Saltar al contenido principal
Jordi Izquierdo Montalbán
Lo que practico

Fundamentos

Programación y calidad

Código legible, funciones pequeñas y refactorización con tests detrás.

Qué significa «buen código»

El código se lee muchas más veces de las que se escribe, y casi todo el coste de un sistema está en cambiarlo, no en crearlo. Por eso la calidad no es elegancia ni ingenio: es cuánto cuesta entender y modificar el código sin romper nada. Un buen criterio de partida:

  • Correcto: hace lo que debe, también en los casos límite.
  • Legible: otra persona (o uno mismo dentro de seis meses) entiende la intención sin descifrarlo.
  • Modificable: un cambio de requisito toca pocos sitios.
  • Comprobable: se puede verificar con pruebas rápidas y deterministas.
  • Simple: no contiene más de lo que hace falta hoy.

Una secuencia clásica de trabajo es: hacer que funcione, hacerlo bien, hacerlo rápido, y esto último solo si se mide que hace falta.

Nombres: el 80 % de la legibilidad

Un buen nombre elimina la necesidad de un comentario.

MalMejorPor qué
d, tmp, datadiasDesdeAlta, pedidoPendienteDice qué es, no de qué tipo
procesar()calcularImporteConIva()El verbo y el objeto describen el efecto
flag, okestaVerificado, tienePermisoUn booleano se lee como una pregunta
getUsuarioYGuardar()Dos funciones separadasUn nombre con «y» delata dos responsabilidades
GestorDeCosasCalculadoraDeEnvio«Gestor», «Manager» o «Util» no informan

Reglas prácticas:

  • Los nombres son pronunciables y buscables; evitar abreviaturas privadas.
  • Una palabra por concepto en todo el código (no mezclar obtener, traer y recuperar).
  • El nombre crece con el alcance: una variable de un bucle corto puede ser i; un campo público, no.
  • Se usa el vocabulario del dominio del negocio, el mismo que usan las personas que piden las funcionalidades.

Funciones y métodos

  • Hacen una cosa, al mismo nivel de abstracción, y se leen de arriba abajo como una narración.
  • Pocos parámetros (tres ya son sospechosos). Si siempre viajan juntos, son un concepto con nombre propio.
  • Sin parámetros booleanos «bandera» que cambian el comportamiento: exportar(informe, true) no dice nada. Mejor dos funciones o un tipo enumerado.
  • Separar órdenes de consultas: o cambia el estado o devuelve un valor, no ambas cosas a la vez.
  • Salidas tempranas (cláusulas de guarda) en lugar de condicionales anidados.

Antes y después de una cláusula de guarda:

// Antes: anidamiento que oculta el camino normal
function precioFinal(pedido: Pedido): number {
  if (pedido) {
    if (pedido.lineas.length > 0) {
      if (!pedido.cancelado) {
        return pedido.lineas.reduce((t, l) => t + l.precio * l.cantidad, 0);
      }
    }
  }
  return 0;
}
 
// Después: los casos excepcionales salen primero
function precioFinal(pedido: Pedido): number {
  if (pedido.cancelado || pedido.lineas.length === 0) return 0;
  return pedido.lineas.reduce((total, l) => total + l.precio * l.cantidad, 0);
}

Cohesión y acoplamiento

El objetivo: piezas muy cohesionadas por dentro y poco acopladas entre sí.
  • Cohesión: cuánto se pertenecen entre sí las partes de un módulo. Alta cohesión significa que todo lo que hay dentro cambia por el mismo motivo.
  • Acoplamiento: cuánto sabe un módulo de otro. Bajo acoplamiento significa que se puede cambiar uno sin tocar el otro.

Formas de acoplamiento, de peor a mejor: depender de datos internos de otro módulo; compartir estado global; depender de su orden de llamada; depender de su estructura de datos; depender solo de su interfaz. La ley de Deméter resume la dirección a seguir: hablar con los colaboradores directos, no con los colaboradores de los colaboradores (pedido.cliente.direccion.pais es una cadena de conocimiento que rompe en cuanto algo cambia).

Principios SOLID con código

Cinco reglas para que el código admita cambios sin romperse.

S: responsabilidad única

Una clase tiene una razón para cambiar. Antes, una clase que calcula, persiste y notifica:

class Factura {
  calcularTotal() { /* reglas de impuestos */ }
  guardarEnBaseDeDatos() { /* SQL */ }
  enviarPorCorreo() { /* SMTP */ }
}

Después, cada responsabilidad en su sitio:

class CalculadoraDeTotales {
  calcular(factura: Factura): number { /* reglas de impuestos */ return 0; }
}
interface RepositorioDeFacturas {
  guardar(factura: Factura): Promise<void>;
}
interface Notificador {
  avisarFactura(factura: Factura): Promise<void>;
}

O: abierto/cerrado

Abierto a extensión, cerrado a modificación. Antes, cada tipo nuevo obliga a tocar la función:

function descuento(cliente: Cliente): number {
  if (cliente.tipo === "vip") return 0.2;
  if (cliente.tipo === "empleado") return 0.3;
  return 0;
}

Después, un tipo nuevo es una pieza nueva, sin editar lo existente:

interface ReglaDeDescuento {
  aplica(cliente: Cliente): boolean;
  porcentaje(): number;
}
 
const descuento = (cliente: Cliente, reglas: ReglaDeDescuento[]): number =>
  reglas.find((regla) => regla.aplica(cliente))?.porcentaje() ?? 0;

L: sustitución de Liskov

Una implementación debe poder reemplazar a su tipo base sin sorpresas. El ejemplo clásico de violación es la subclase que lanza una excepción en un método heredado o debilita una garantía. Señal de alarma: código que pregunta «¿de qué subtipo eres?» antes de usar un objeto. Si hace falta, la jerarquía está mal planteada; suele ser mejor composición que herencia.

I: segregación de interfaces

Nadie debe depender de métodos que no usa. Antes:

interface Trabajador {
  trabajar(): void;
  comer(): void;
  cobrar(): void;
}
// Un robot que implementa Trabajador queda obligado a comer.

Después, interfaces pequeñas por rol:

interface Trabaja { trabajar(): void }
interface Come { comer(): void }
interface Cobra { cobrar(): void }

D: inversión de dependencias

La lógica depende de abstracciones, y los detalles las implementan. Antes, el caso de uso crea su propia dependencia:

class RegistrarUsuario {
  private db = new PostgresUsuarios(); // acoplado a un detalle
  ejecutar(u: Usuario) { return this.db.insertar(u); }
}

Después, la dependencia se recibe y se puede sustituir (en pruebas, por una versión en memoria):

class RegistrarUsuario {
  constructor(private readonly usuarios: RepositorioDeUsuarios) {}
  ejecutar(u: Usuario) { return this.usuarios.guardar(u); }
}

Otros principios que ahorran problemas

PrincipioIdeaCuidado con
DRYCada conocimiento tiene un único lugarUnificar código que solo se parece pero cambia por motivos distintos
KISSLa solución más simple que funcioneConfundir simple con simplista
YAGNINo construir lo que aún no se necesitaUsarlo como excusa para ignorar el diseño evidente
Ley de DeméterHablar solo con los colaboradores directosCrear métodos delegadores por todas partes
Composición sobre herenciaCombinar piezas en lugar de heredarReinventar la herencia con delegación torpe
Principio del menor asombroEl código hace lo que pareceSorpresas ocultas en getters o constructores

Manejo de errores

  • Fallar pronto y con claridad: validar en la frontera del módulo y rechazar lo inválido de inmediato.
  • Errores como parte del contrato: usar tipos de error propios o resultados explícitos en lugar de códigos mágicos o null.
  • No tragarse excepciones. Capturar solo lo que se sabe tratar; si no, dejar que suba.
  • Conservar la causa original al envolver un error.
  • No usar excepciones para el flujo normal.
  • Limpiar siempre los recursos (cierres, bloqueos) incluso si hay error.
// Antes: el error se traga y el fallo se oculta
try {
  guardar(pedido);
} catch (e) {
  console.log("error");
}
 
// Después: se trata lo que se sabe tratar y se conserva la causa
try {
  guardar(pedido);
} catch (error) {
  if (error instanceof ConflictoDeVersion) return reintentar(pedido);
  throw new ErrorAlGuardarPedido(pedido.id, { cause: error });
}

Un valor nulo no controlado es una fuente clásica de fallos. Mejor modelar la ausencia en el tipo (Usuario | undefined, un tipo opcional o un objeto nulo) y hacer que el compilador obligue a tratarla.

Inmutabilidad y estado

  • Preferir datos inmutables: no pueden cambiar bajo los pies de otra parte del código, son seguros entre hilos y fáciles de razonar.
  • Funciones puras (misma entrada, misma salida, sin efectos) para la lógica; los efectos (disco, red, reloj) en los bordes.
  • Reducir el alcance de las variables y evitar el estado global compartido.
  • Hacer que los estados imposibles sean irrepresentables con tipos: en lugar de tres booleanos que admiten combinaciones absurdas, un tipo unión con los estados válidos.
type Pedido =
  | { estado: "borrador" }
  | { estado: "pagado"; pagadoEn: Date }
  | { estado: "enviado"; pagadoEn: Date; seguimiento: string };

Complejidad: qué medir y cómo reducirla

MedidaQué diceCómo se usa
Complejidad ciclomáticaNúmero de caminos independientes de una funciónMás caminos, más pruebas necesarias; un umbral de equipo avisa de funciones a dividir
Complejidad cognitivaCuánto cuesta leer la función (penaliza el anidamiento)Se aproxima mejor a la dificultad humana de comprensión
Acoplamiento entre módulosDe cuántos otros depende un móduloDetecta piezas frágiles ante cambios
CohesiónSi los miembros de una clase se usan juntosDetecta clases que mezclan temas
Tamaño y profundidad de anidamientoVolumen y estructuraIndicadores rápidos, no objetivos
Frecuencia de cambioQué ficheros se modifican másCombinado con complejidad, señala dónde refactorizar primero

Las métricas orientan la revisión, no la sustituyen, y se vuelven inútiles si se convierten en un objetivo (se «optimizan» sin mejorar nada). Para reducir complejidad: extraer funciones con nombre, usar cláusulas de guarda, sustituir condicionales por polimorfismo o por tablas de datos, y separar la decisión de la ejecución.

Code smells y su refactorización

Los code smells del catálogo de Fowler no son errores: son indicios de que el diseño pide atención.

OlorSeñalRefactorización habitual
Método largoHace varias cosas, difícil de resumirExtraer método
Clase grandeMuchos campos y responsabilidadesExtraer clase
Lista larga de parámetrosDatos que siempre viajan juntosIntroducir objeto de parámetros
Código duplicadoEl mismo cambio en varios sitiosExtraer método o subir a una abstracción
Cambio divergenteUna clase cambia por motivos distintosSeparar por responsabilidad
Cirugía con escopetaUn cambio toca muchas clasesMover método o campo; agrupar
Envidia de característicasUn método usa más datos de otra clase que los suyosMover método
Grupos de datosLos mismos campos aparecen siempre juntosExtraer clase o tipo propio
Obsesión por los primitivosCadenas y números en lugar de conceptosReemplazar primitivo por objeto de valor
Sentencias switch repetidasEl mismo condicional en varios puntosReemplazar condicional por polimorfismo
Generalidad especulativaAbstracciones para casos que no existenColapsar jerarquía, eliminar
Código muertoNadie lo llamaBorrarlo (el control de versiones lo recuerda)
Cadena de mensajesa.b().c().d()Ocultar delegado
IntermediarioUna clase que solo delegaEliminar al intermediario
Comentarios que explican «qué»El código no se explicaRenombrar y extraer; comentar solo el «por qué»

Ejemplo: reemplazar condicional por polimorfismo

Antes, un condicional repetido que crece con cada tipo:

function cuotaMensual(cuenta: Cuenta): number {
  switch (cuenta.tipo) {
    case "basica": return 0;
    case "premium": return 9.9;
    case "empresa": return 29.9 + cuenta.usuarios * 2;
    default: throw new Error("Tipo desconocido");
  }
}

Después, con el patrón estrategia, cada tipo conoce su regla:

Sustituye un bloque de condicionales por piezas intercambiables.
interface PlanDeCuota {
  cuotaMensual(cuenta: Cuenta): number;
}
 
const planes: Record<TipoDeCuenta, PlanDeCuota> = {
  basica: { cuotaMensual: () => 0 },
  premium: { cuotaMensual: () => 9.9 },
  empresa: { cuotaMensual: (c) => 29.9 + c.usuarios * 2 },
};
 
const cuotaMensual = (cuenta: Cuenta): number => planes[cuenta.tipo].cuotaMensual(cuenta);

Refactorización segura

Cambiar la forma del código sin cambiar lo que hace.

Refactorizar es mejorar la estructura sin cambiar el comportamiento observable. Sin pruebas que lo comprueben, no es refactorizar: es reescribir con riesgo.

Reglas del oficio:

  1. Red de seguridad primero: pruebas que fijen el comportamiento actual (si no existen, pruebas de caracterización).
  2. Pasos minúsculos, cada uno verificable.
  3. Un sombrero cada vez: o se cambia el comportamiento, o se cambia la estructura; nunca ambas cosas en el mismo commit.
  4. Apoyarse en el entorno: las refactorizaciones automáticas del IDE (renombrar, extraer, mover) son más seguras que a mano.
  5. Regla del boy scout: dejar el código un poco mejor de como se encontró, sin convertirlo en una reescritura.
  6. Técnicas para código sin pruebas: introducir una costura (punto donde sustituir una dependencia), cubrir y después refactorizar.

Patrones de diseño más útiles (y cuándo sobran)

Los patrones son vocabulario compartido, no un catálogo de obligaciones.

PatrónProblema que resuelveSe queda en sobreingeniería cuando
EstrategiaVarias formas intercambiables de hacer algoHay solo una variante y no se prevé otra
FábricaCentralizar la creación de objetos que varíanSe crea siempre el mismo tipo con un constructor trivial
AdaptadorHacer compatible una interfaz ajena con la propiaSe envuelve un código propio que podría cambiarse directamente
ObservadorNotificar a interesados sin acoplarlosEl flujo es lineal y las notificaciones hacen el sistema impredecible
RepositorioAislar el acceso a datos tras una interfaz de dominioEs un CRUD sin lógica, solo duplica al ORM
DecoradorAñadir comportamiento envolviendo un objetoSe apilan capas que nadie sabe en qué orden aplican
Orden (command)Representar una acción como un objeto (deshacer, colas)No se necesita deshacer, registrar ni encolar
Objeto de valorTipos con significado e igualdad por valorSe envuelve cada primitivo sin que aporte reglas

Criterio para decidir: un patrón se introduce cuando el dolor ya existe, no antes. La tercera repetición de un problema suele ser el momento; la primera, no. Los nombres de patrón en clases (FabricaDeFabricas, GestorAbstractoDeEstrategias) son una señal de alarma.

Tipado y análisis estático

  • Tipos como documentación comprobada: cada firma dice qué entra y qué sale, y el compilador lo vigila.
  • Modo estricto activado desde el primer día; relajar las reglas después es mucho más caro que empezarlas relajadas.
  • Evitar los escapes del sistema de tipos (any, conversiones forzadas): cada uno es una promesa sin comprobar.
  • Validar en la frontera: los datos externos (red, ficheros, formularios) no tienen tipo real hasta que se validan contra un esquema.
  • Linter: detecta errores y malas prácticas sin ejecutar el código. Las reglas se acuerdan en equipo y se aplican en integración continua.
  • Formateador automático: acaba con las discusiones de estilo y reduce el ruido en las revisiones.
  • Análisis de dependencias y de seguridad como paso de la tubería.

Pruebas como parte del diseño

Muchas comprobaciones baratas en la base, pocas costosas arriba.
  • Código difícil de probar suele ser código mal diseñado: demasiado acoplado o con efectos escondidos.
  • Pruebas unitarias rápidas y deterministas para la lógica; de integración para las costuras con el mundo real; pocas de extremo a extremo para los flujos críticos.
  • Una prueba cuenta una historia: preparar, actuar, comprobar. Un solo motivo para fallar.
  • Probar comportamiento, no implementación: si una refactorización inocua rompe veinte pruebas, estaban atadas a los detalles.
  • Dobles de prueba (simulacros, falsos) solo en las fronteras; abusar de ellos deja pruebas que verifican la maqueta y no el sistema.
  • Desarrollo guiado por pruebas: rojo, verde, refactor. Escribir primero la prueba obliga a pensar en la interfaz antes que en la implementación.

Revisión de código

Cambios pequeños, comprobaciones automáticas primero y personas después.

La revisión sirve para compartir conocimiento y atrapar lo que las máquinas no ven: diseño, claridad, casos límite, seguridad. Lo mecánico (formato, estilo, tipos) lo hacen las herramientas antes de que intervenga una persona.

Qué mirar, en orden:

  1. Qué problema resuelve y si el enfoque es el adecuado.
  2. Corrección y casos límite: vacíos, nulos, concurrencia, errores.
  3. Diseño: ¿encaja con la estructura existente? ¿se entiende la intención?
  4. Pruebas: ¿cubren el comportamiento nuevo y los fallos probables?
  5. Seguridad y datos: validación de entradas, permisos, secretos.
  6. Legibilidad: nombres, tamaño de funciones, comentarios útiles.

Cómo hacerla bien:

  • Cambios pequeños y con una sola intención: son más fáciles de revisar y de revertir.
  • Comentarios sobre el código, no sobre la persona; preguntas antes que órdenes.
  • Distinguir lo obligatorio de lo opcional (marcar las sugerencias menores como tales).
  • Respuesta rápida: una revisión que espera días frena a todo el equipo.
  • Quien envía el cambio lo explica (contexto, decisiones, cómo probarlo).

Deuda técnica

La deuda técnica es el coste futuro de atajos tomados hoy. Como la financiera, tiene principal (el trabajo pendiente) e intereses (el sobrecoste de cada cambio mientras no se paga).

La deuda se hace visible, se prioriza y se paga en pequeñas cuotas.
TipoEjemploCómo se trata
Deliberada y prudenteSe lanza con una solución simple para validar el mercado y se anotaEs una decisión legítima, con fecha de revisión
Deliberada e imprudente«No hay tiempo para diseñar»Evitarla: el interés llega pronto
InadvertidaSe descubre tarde que el diseño era inadecuadoAprender y refactorizar cuando se descubre
Por obsolescenciaDependencias y plataformas sin mantenimientoMantenimiento periódico planificado

Gestión práctica:

  • Hacerla visible: registrar la deuda con su coste de no pagarla.
  • Priorizar donde se cambia mucho: la deuda en código que nadie toca apenas cuesta.
  • Pagarla en cuotas: refactorizar junto a los cambios funcionales, en lugar de proyectos de «limpieza» interminables.
  • No acumular deuda en silencio: una decisión de atajo se documenta.

Flujo de trabajo diario

  1. Entender el cambio y su criterio de aceptación antes de escribir.
  2. Escribir la prueba que falla por la razón correcta.
  3. Hacerla pasar con lo más simple.
  4. Refactorizar con las pruebas en verde: nombres, extracción, eliminación de duplicados.
  5. Pasar las herramientas: formateador, linter, comprobador de tipos, pruebas.
  6. Revisar el propio cambio antes de pedir revisión: leerlo como si fuera de otro.
  7. Integrar en pequeño, a menudo, con la rama principal siempre desplegable.

Lista de comprobación antes de integrar

  • Los nombres explican la intención sin necesidad de comentarios.
  • Ninguna función hace más de una cosa ni mezcla niveles de abstracción.
  • Los errores se tratan o se propagan con su causa; ninguno se traga.
  • No hay código muerto, depuración olvidada ni secretos.
  • Las pruebas cubren el comportamiento nuevo y los casos límite.
  • El formateador, el linter y los tipos pasan sin silenciar avisos.
  • El cambio hace una sola cosa y se puede revisar en pocos minutos.
  • La estructura queda igual o mejor que antes.

Antipatrones habituales

  • Optimización prematura: complicar el código sin haber medido un problema real.
  • Abstracción prematura: generalizar con un solo caso de uso y pagar la indirección para siempre.
  • Código espagueti: flujo de control enredado, saltos y estado compartido por todas partes.
  • Dios objeto: una clase que lo sabe y lo hace todo.
  • Copiar y pegar: duplicar en lugar de entender y extraer.
  • Números y cadenas mágicas: valores sin nombre repartidos por el código.
  • Programación por coincidencia: cambiar cosas hasta que funcione sin saber por qué.
  • Mezclar refactorización y cambio de comportamiento en el mismo commit.
  • Silenciar al linter en lugar de arreglar la causa.
  • Capturar todas las excepciones y continuar como si nada.
  • Comentarios mentirosos: que describen lo que el código hacía hace tres versiones.
  • Reescribir desde cero un sistema que funciona, en lugar de mejorarlo por partes.