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.
| Mal | Mejor | Por qué |
|---|---|---|
d, tmp, data | diasDesdeAlta, pedidoPendiente | Dice qué es, no de qué tipo |
procesar() | calcularImporteConIva() | El verbo y el objeto describen el efecto |
flag, ok | estaVerificado, tienePermiso | Un booleano se lee como una pregunta |
getUsuarioYGuardar() | Dos funciones separadas | Un nombre con «y» delata dos responsabilidades |
GestorDeCosas | CalculadoraDeEnvio | «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,traeryrecuperar). - 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
Cada módulo, a lo suyo
Acoplamiento bajo: se cambia uno sin tocar el otro.
- 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
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
| Principio | Idea | Cuidado con |
|---|---|---|
| DRY | Cada conocimiento tiene un único lugar | Unificar código que solo se parece pero cambia por motivos distintos |
| KISS | La solución más simple que funcione | Confundir simple con simplista |
| YAGNI | No construir lo que aún no se necesita | Usarlo como excusa para ignorar el diseño evidente |
| Ley de Deméter | Hablar solo con los colaboradores directos | Crear métodos delegadores por todas partes |
| Composición sobre herencia | Combinar piezas en lugar de heredar | Reinventar la herencia con delegación torpe |
| Principio del menor asombro | El código hace lo que parece | Sorpresas 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
| Medida | Qué dice | Cómo se usa |
|---|---|---|
| Complejidad ciclomática | Número de caminos independientes de una función | Más caminos, más pruebas necesarias; un umbral de equipo avisa de funciones a dividir |
| Complejidad cognitiva | Cuánto cuesta leer la función (penaliza el anidamiento) | Se aproxima mejor a la dificultad humana de comprensión |
| Acoplamiento entre módulos | De cuántos otros depende un módulo | Detecta piezas frágiles ante cambios |
| Cohesión | Si los miembros de una clase se usan juntos | Detecta clases que mezclan temas |
| Tamaño y profundidad de anidamiento | Volumen y estructura | Indicadores rápidos, no objetivos |
| Frecuencia de cambio | Qué ficheros se modifican más | Combinado 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.
| Olor | Señal | Refactorización habitual |
|---|---|---|
| Método largo | Hace varias cosas, difícil de resumir | Extraer método |
| Clase grande | Muchos campos y responsabilidades | Extraer clase |
| Lista larga de parámetros | Datos que siempre viajan juntos | Introducir objeto de parámetros |
| Código duplicado | El mismo cambio en varios sitios | Extraer método o subir a una abstracción |
| Cambio divergente | Una clase cambia por motivos distintos | Separar por responsabilidad |
| Cirugía con escopeta | Un cambio toca muchas clases | Mover método o campo; agrupar |
| Envidia de características | Un método usa más datos de otra clase que los suyos | Mover método |
| Grupos de datos | Los mismos campos aparecen siempre juntos | Extraer clase o tipo propio |
| Obsesión por los primitivos | Cadenas y números en lugar de conceptos | Reemplazar primitivo por objeto de valor |
| Sentencias switch repetidas | El mismo condicional en varios puntos | Reemplazar condicional por polimorfismo |
| Generalidad especulativa | Abstracciones para casos que no existen | Colapsar jerarquía, eliminar |
| Código muerto | Nadie lo llama | Borrarlo (el control de versiones lo recuerda) |
| Cadena de mensajes | a.b().c().d() | Ocultar delegado |
| Intermediario | Una clase que solo delega | Eliminar al intermediario |
| Comentarios que explican «qué» | El código no se explica | Renombrar 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:
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
En rojo: se deshace el último paso. En verde: se guarda y se repite.
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:
- Red de seguridad primero: pruebas que fijen el comportamiento actual (si no existen, pruebas de caracterización).
- Pasos minúsculos, cada uno verificable.
- Un sombrero cada vez: o se cambia el comportamiento, o se cambia la estructura; nunca ambas cosas en el mismo commit.
- Apoyarse en el entorno: las refactorizaciones automáticas del IDE (renombrar, extraer, mover) son más seguras que a mano.
- Regla del boy scout: dejar el código un poco mejor de como se encontró, sin convertirlo en una reescritura.
- 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ón | Problema que resuelve | Se queda en sobreingeniería cuando |
|---|---|---|
| Estrategia | Varias formas intercambiables de hacer algo | Hay solo una variante y no se prevé otra |
| Fábrica | Centralizar la creación de objetos que varían | Se crea siempre el mismo tipo con un constructor trivial |
| Adaptador | Hacer compatible una interfaz ajena con la propia | Se envuelve un código propio que podría cambiarse directamente |
| Observador | Notificar a interesados sin acoplarlos | El flujo es lineal y las notificaciones hacen el sistema impredecible |
| Repositorio | Aislar el acceso a datos tras una interfaz de dominio | Es un CRUD sin lógica, solo duplica al ORM |
| Decorador | Añadir comportamiento envolviendo un objeto | Se 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 valor | Tipos con significado e igualdad por valor | Se 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
- 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
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:
- Qué problema resuelve y si el enfoque es el adecuado.
- Corrección y casos límite: vacíos, nulos, concurrencia, errores.
- Diseño: ¿encaja con la estructura existente? ¿se entiende la intención?
- Pruebas: ¿cubren el comportamiento nuevo y los fallos probables?
- Seguridad y datos: validación de entradas, permisos, secretos.
- 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).
| Tipo | Ejemplo | Cómo se trata |
|---|---|---|
| Deliberada y prudente | Se lanza con una solución simple para validar el mercado y se anota | Es 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 |
| Inadvertida | Se descubre tarde que el diseño era inadecuado | Aprender y refactorizar cuando se descubre |
| Por obsolescencia | Dependencias y plataformas sin mantenimiento | Mantenimiento 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
- Entender el cambio y su criterio de aceptación antes de escribir.
- Escribir la prueba que falla por la razón correcta.
- Hacerla pasar con lo más simple.
- Refactorizar con las pruebas en verde: nombres, extracción, eliminación de duplicados.
- Pasar las herramientas: formateador, linter, comprobador de tipos, pruebas.
- Revisar el propio cambio antes de pedir revisión: leerlo como si fuera de otro.
- 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.