Introducción

Muchas veces confundimos Clean Code con "código corto". No es eso. Un bloque de cinco líneas puede ser dificilísimo de modificar si esconde reglas de negocio, mezcla niveles de abstracción o obliga a recordar qué significa cada boolean.

En este post vamos a refactorizar un caso de uso muy habitual: aplicar un descuento a un pedido. El objetivo no es aplicar una lista de reglas de memoria, sino dejar el código en un estado en el que la siguiente persona pueda responder rápido a tres preguntas: qué hace, cuándo falla y qué datos necesita.

El punto de partida

Imaginemos que recibimos un pedido y un cupón. Esta primera versión funciona, pero empieza a ser incómoda cuando aparecen nuevas reglas:

apply-discount.ts
type Order = {
total: number
status: "draft" | "paid" | "cancelled"
customer: { isPremium: boolean }
}

type Coupon = {
code: string
percentage: number
expiresAt: Date
premiumOnly: boolean
}

export function applyDiscount(order: Order, coupon: Coupon, now: Date) {
if (order.status !== "cancelled") {
if (order.status !== "paid") {
if (coupon.expiresAt > now) {
if (!coupon.premiumOnly || order.customer.isPremium) {
if (coupon.percentage > 0 && coupon.percentage <= 100) {
return order.total - (order.total * coupon.percentage) / 100
}
}
}
}
}

return order.total
}

El problema no es solo la anidación. Al leerlo tenemos que invertir mentalmente varias condiciones para entender el camino feliz. Además, devolver el total original para cualquier error borra información útil: un cupón caducado y un pedido ya pagado no son el mismo problema.

Guard clauses: sacar los casos que no pueden continuar

Las guard clauses son retornos tempranos para los casos que impiden continuar. Dejan el camino principal al mismo nivel de indentación y hacen visibles las reglas de entrada.

apply-discount.ts
export class DiscountError extends Error {}

export function applyDiscount(order: Order, coupon: Coupon, now: Date) {
if (order.status === "cancelled") {
throw new DiscountError("No se puede descontar un pedido cancelado")
}

if (order.status === "paid") {
throw new DiscountError("No se puede modificar un pedido pagado")
}

if (coupon.expiresAt <= now) {
throw new DiscountError("El cupón ha caducado")
}

if (coupon.premiumOnly && !order.customer.isPremium) {
throw new DiscountError("El cupón es exclusivo para clientes premium")
}

if (coupon.percentage <= 0 || coupon.percentage > 100) {
throw new DiscountError("El porcentaje de descuento no es válido")
}

return order.total - (order.total * coupon.percentage) / 100
}

Ahora el final de la función cuenta una historia muy simple: si todas las reglas se cumplen, aplica el descuento. Los fallos aparecen donde ocurren y pueden transformarse en mensajes HTTP o de interfaz en la capa adecuada.

Extraer una regla cuando tiene nombre propio

No conviene convertir cada condición en una función privada. Pero si una expresión representa un concepto del dominio, darle nombre reduce carga cognitiva y evita duplicarla.

coupon.ts
type Coupon = {
code: string
percentage: number
expiresAt: Date
premiumOnly: boolean
}

export function isCouponActive(coupon: Coupon, now: Date): boolean {
return coupon.expiresAt > now
}

export function canCustomerUseCoupon(
coupon: Coupon,
customer: { isPremium: boolean }
): boolean {
return !coupon.premiumOnly || customer.isPremium
}

El caso de uso queda centrado en decisiones, no en detalles de comparación:

apply-discount.ts
if (!isCouponActive(coupon, now)) {
throw new DiscountError("El cupón ha caducado")
}

if (!canCustomerUseCoupon(coupon, order.customer)) {
throw new DiscountError("El cupón es exclusivo para clientes premium")
}

Un buen nombre no sustituye a la lógica; la coloca en el vocabulario del problema. isCouponActive comunica más que expiresAt > now, especialmente cuando esa regla termine incorporando una fecha de activación o una zona horaria.

Haz que los estados inválidos sean difíciles de representar

TypeScript no impide por sí solo que alguien cree un cupón con percentage: -20. Pero podemos concentrar esa validación en un constructor y exponer un tipo que solo se obtiene si el valor es válido.

percentage.ts
declare const percentageBrand: unique symbol

export type Percentage = number & {
readonly [percentageBrand]: "Percentage"
}

export function createPercentage(value: number): Percentage {
if (!Number.isFinite(value) || value <= 0 || value > 100) {
throw new RangeError("El porcentaje debe estar entre 1 y 100")
}

return value as Percentage
}

El branding no es magia: en ejecución sigue siendo un number. Pero hace explícita la frontera de validación. A partir de ahí, una función que recibe Percentage puede asumir que el valor ya cumple el contrato.

apply-percentage.ts
import { Percentage } from "./percentage"

export function applyPercentage(total: number, percentage: Percentage): number {
return total - (total * percentage) / 100
}

No hace falta aplicar este patrón a cada string o número. Es especialmente útil en valores que cruzan varias capas y cuyas restricciones son importantes para el negocio: dinero, porcentajes, emails o identificadores.

Tests que describen comportamiento

La refactorización es más segura cuando los tests hablan del comportamiento observado, no de las funciones internas. En este caso los nombres de las pruebas pueden funcionar como una pequeña especificación.

apply-discount.test.ts
describe("applyDiscount", () => {
const draftOrder = {
total: 100,
status: "draft" as const,
customer: { isPremium: false },
}

it("aplica un cupón válido al pedido", () => {
const total = applyDiscount(
draftOrder,
{ code: "WELCOME10", percentage: 10, expiresAt: new Date("2027-01-01"), premiumOnly: false },
new Date("2026-09-27")
)

expect(total).toBe(90)
})

it("rechaza un cupón caducado", () => {
expect(() => applyDiscount(
draftOrder,
{ code: "OLD", percentage: 10, expiresAt: new Date("2026-01-01"), premiumOnly: false },
new Date("2026-09-27")
)).toThrow("El cupón ha caducado")
})
})

Fíjate en que no comprobamos si se llamó a isCouponActive. Esa es una decisión de implementación que deberíamos poder cambiar sin reescribir los tests del caso de uso.

Una lista práctica antes de cerrar el editor

Cuando una función empieza a crecer, me ayuda revisar estas preguntas:

  • ¿Su nombre expresa una acción y su resultado?
  • ¿Los casos de salida temprana están al principio?
  • ¿Cada bloque trabaja al mismo nivel de abstracción?
  • ¿Hay un boolean o un número cuyo significado merezca un tipo o un nombre?
  • ¿Los tests describen reglas del negocio y no la estructura interna?

Conclusión

Clean Code no busca un estilo rígido ni funciones minúsculas por deporte. Busca que modificar una regla sea barato y que el código explique por qué toma una decisión.

Empezar por guard clauses, nombres de dominio y tipos en las fronteras suele dar un retorno inmediato. Después, si el caso de uso crece, será mucho más sencillo llevar esas reglas a entidades y casos de uso dentro de una arquitectura limpia.