11. Eliminar duplicación sin crear abstracciones prematuras

11.1 Objetivo del tema

La duplicación de código aumenta el costo de mantenimiento porque una misma regla debe cambiarse en varios lugares. Pero eliminar toda similitud de inmediato también puede producir abstracciones confusas, llenas de parámetros y difíciles de entender.

En este tema aprenderemos a distinguir duplicación real de similitud superficial. Practicaremos cómo extraer comportamiento común en Python sin forzar abstracciones prematuras y verificando cada paso con pruebas.

Objetivo práctico: reducir duplicación útilmente, sin ocultar diferencias importantes ni crear funciones genéricas difíciles de usar.

11.2 Duplicación real y similitud accidental

Dos fragmentos pueden parecer iguales, pero representar decisiones distintas. Antes de extraer una abstracción, conviene preguntar si realmente cambian por la misma razón.

  • Duplicación real: la misma regla de negocio aparece repetida.
  • Similitud accidental: dos bloques se parecen hoy, pero responden a reglas diferentes.

Eliminar duplicación real suele mejorar el diseño. Unificar similitudes accidentales puede acoplar partes que deberían evolucionar por separado.

11.3 Código inicial

Crea el archivo src/facturacion.py:

def calcular_factura_minorista(items, cliente):
    subtotal = 0
    for item in items:
        subtotal = subtotal + item["precio"] * item["cantidad"]

    if cliente["tipo"] == "vip":
        subtotal = subtotal * 0.9

    impuesto = subtotal * 0.21
    total = subtotal + impuesto

    return round(total, 2)


def calcular_factura_mayorista(items, cliente):
    subtotal = 0
    for item in items:
        subtotal = subtotal + item["precio"] * item["cantidad"]

    if cliente["tipo"] == "distribuidor":
        subtotal = subtotal * 0.85

    impuesto = subtotal * 0.21
    total = subtotal + impuesto

    return round(total, 2)

Hay duplicación clara en el cálculo del subtotal, el impuesto y el redondeo. Pero los descuentos responden a reglas distintas.

11.4 Pruebas antes de refactorizar

Crea tests/test_facturacion.py:

from facturacion import calcular_factura_mayorista, calcular_factura_minorista


def test_calcula_factura_minorista_vip():
    items = [
        {"precio": 1000, "cantidad": 2},
        {"precio": 500, "cantidad": 1},
    ]
    cliente = {"tipo": "vip"}

    assert calcular_factura_minorista(items, cliente) == 2722.5


def test_calcula_factura_mayorista_distribuidor():
    items = [
        {"precio": 1000, "cantidad": 2},
        {"precio": 500, "cantidad": 1},
    ]
    cliente = {"tipo": "distribuidor"}

    assert calcular_factura_mayorista(items, cliente) == 2571.25

Ejecuta:

python -m pytest tests/test_facturacion.py

11.5 Extraer duplicación evidente

El cálculo del subtotal está repetido exactamente y tiene el mismo significado en ambos casos. Es un buen candidato para extraer.

def calcular_subtotal(items):
    subtotal = 0
    for item in items:
        subtotal = subtotal + item["precio"] * item["cantidad"]
    return subtotal

Luego lo usamos en ambas funciones:

def calcular_factura_minorista(items, cliente):
    subtotal = calcular_subtotal(items)

    if cliente["tipo"] == "vip":
        subtotal = subtotal * 0.9

    impuesto = subtotal * 0.21
    total = subtotal + impuesto

    return round(total, 2)

Después del cambio ejecuta python -m pytest tests/test_facturacion.py.

11.6 Extraer impuesto

El cálculo del impuesto también está duplicado y representa la misma regla.

IVA = 0.21


def calcular_impuesto(subtotal):
    return subtotal * IVA

Ahora ambas funciones pueden usar la misma regla:

impuesto = calcular_impuesto(subtotal)
total = subtotal + impuesto

Este cambio reduce duplicación sin mezclar reglas de descuento diferentes.

11.7 Extraer total con impuesto

Podemos ir un paso más y extraer el cálculo del total final:

def calcular_total_con_impuesto(subtotal):
    return round(subtotal + calcular_impuesto(subtotal), 2)

Las funciones principales quedan así:

def calcular_factura_minorista(items, cliente):
    subtotal = calcular_subtotal(items)

    if cliente["tipo"] == "vip":
        subtotal = subtotal * 0.9

    return calcular_total_con_impuesto(subtotal)


def calcular_factura_mayorista(items, cliente):
    subtotal = calcular_subtotal(items)

    if cliente["tipo"] == "distribuidor":
        subtotal = subtotal * 0.85

    return calcular_total_con_impuesto(subtotal)

Ejecuta las pruebas antes de seguir.

11.8 No unificar descuentos demasiado pronto

Podríamos intentar crear una función genérica para descuentos:

def aplicar_descuento(subtotal, cliente, tipo, porcentaje):
    if cliente["tipo"] == tipo:
        return subtotal * (1 - porcentaje)
    return subtotal

Funciona, pero puede ser una abstracción prematura. El descuento minorista y el mayorista podrían cambiar por razones distintas. Si todavía no sabemos si esas reglas evolucionarán juntas, conviene mantenerlas separadas o nombrarlas de forma específica.

11.9 Extraer reglas específicas sin forzar generalidad

Una alternativa más clara es extraer funciones específicas:

def aplicar_descuento_minorista(subtotal, cliente):
    if cliente["tipo"] == "vip":
        return subtotal * 0.9
    return subtotal


def aplicar_descuento_mayorista(subtotal, cliente):
    if cliente["tipo"] == "distribuidor":
        return subtotal * 0.85
    return subtotal

Hay cierta similitud, pero cada función representa una regla del dominio. Si en el futuro ambas reglas convergen, podremos unificarlas con más información.

11.10 Código refactorizado equilibrado

Una versión equilibrada puede quedar así:

IVA = 0.21


def calcular_subtotal(items):
    subtotal = 0
    for item in items:
        subtotal = subtotal + item["precio"] * item["cantidad"]
    return subtotal


def calcular_impuesto(subtotal):
    return subtotal * IVA


def calcular_total_con_impuesto(subtotal):
    return round(subtotal + calcular_impuesto(subtotal), 2)


def aplicar_descuento_minorista(subtotal, cliente):
    if cliente["tipo"] == "vip":
        return subtotal * 0.9
    return subtotal


def aplicar_descuento_mayorista(subtotal, cliente):
    if cliente["tipo"] == "distribuidor":
        return subtotal * 0.85
    return subtotal


def calcular_factura_minorista(items, cliente):
    subtotal = calcular_subtotal(items)
    subtotal = aplicar_descuento_minorista(subtotal, cliente)
    return calcular_total_con_impuesto(subtotal)


def calcular_factura_mayorista(items, cliente):
    subtotal = calcular_subtotal(items)
    subtotal = aplicar_descuento_mayorista(subtotal, cliente)
    return calcular_total_con_impuesto(subtotal)

La duplicación técnica se redujo, pero las diferencias de negocio siguen visibles.

11.11 Duplicación de conocimiento

No toda duplicación se ve como líneas iguales. A veces el problema es repetir el mismo conocimiento con formas distintas.

if total >= 10000:
    envio = 0

# En otro archivo:
if compra["importe"] >= 10000:
    costo_envio = 0

Si 10000 representa el mismo límite de envío gratis, la regla está duplicada aunque el código no sea idéntico. Una constante con nombre ayuda:

LIMITE_ENVIO_GRATIS = 10000

11.12 Duplicación en pruebas

Las pruebas también pueden tener duplicación útil o problemática. Repetir datos explícitos a veces mejora la lectura. Pero si el armado de datos es largo y se repite mucho, conviene crear una función auxiliar.

def crear_item(precio=1000, cantidad=1):
    return {"precio": precio, "cantidad": cantidad}

La regla es la misma: extrae cuando mejora claridad, no solo para reducir líneas.

11.13 Evitar funciones con demasiados parámetros

Una señal de abstracción prematura es una función que intenta cubrir demasiados casos:

def calcular_factura(items, cliente, tipo_descuento, porcentaje, aplica_iva, redondear):
    # demasiadas decisiones mezcladas
    ...

Si una abstracción necesita muchos parámetros de control, tal vez está unificando comportamientos que todavía deberían permanecer separados.

11.14 Regla práctica: esperar la tercera aparición

Una guía útil es no extraer una abstracción grande ante la primera similitud. Cuando aparece una tercera repetición, suele haber más información para entender qué es realmente común y qué cambia.

Esto no significa tolerar duplicación peligrosa. Si una regla crítica aparece dos veces y sabemos que debe cambiar junta, conviene extraerla. La clave es razonar sobre el costo y el riesgo, no aplicar una regla mecánica.

11.15 Ejercicio propuesto

Crea el archivo src/notificaciones.py:

def crear_email_bienvenida(usuario):
    asunto = "Bienvenido " + usuario["nombre"]
    cuerpo = "Hola " + usuario["nombre"] + ", gracias por registrarte."
    cuerpo = cuerpo + " Tu email registrado es " + usuario["email"] + "."
    return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}


def crear_email_recuperacion(usuario):
    asunto = "Recuperar contraseña"
    cuerpo = "Hola " + usuario["nombre"] + ", solicitaste recuperar tu contraseña."
    cuerpo = cuerpo + " Tu email registrado es " + usuario["email"] + "."
    return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}

Realiza estas tareas:

  • Escribe pruebas para ambas funciones.
  • Detecta qué parte de la construcción del mensaje es realmente común.
  • Extrae una función auxiliar sin ocultar la intención de cada email.
  • No crees una función genérica con demasiados parámetros.
  • Ejecuta python -m pytest después de cada cambio.

11.16 Una posible solución

Una solución simple puede extraer solo la parte repetida del email registrado:

def texto_email_registrado(usuario):
    return " Tu email registrado es " + usuario["email"] + "."


def crear_email_bienvenida(usuario):
    asunto = "Bienvenido " + usuario["nombre"]
    cuerpo = "Hola " + usuario["nombre"] + ", gracias por registrarte."
    cuerpo = cuerpo + texto_email_registrado(usuario)
    return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}


def crear_email_recuperacion(usuario):
    asunto = "Recuperar contraseña"
    cuerpo = "Hola " + usuario["nombre"] + ", solicitaste recuperar tu contraseña."
    cuerpo = cuerpo + texto_email_registrado(usuario)
    return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}

No intentamos unificar todos los emails en una sola función genérica. Conservamos la intención de bienvenida y recuperación.

11.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • La diferencia entre duplicación real y similitud accidental.
  • Por qué no toda similitud merece una abstracción.
  • Cómo extraer duplicación técnica sin mezclar reglas de negocio distintas.
  • Qué es duplicación de conocimiento.
  • Cuándo una función con muchos parámetros indica una abstracción forzada.
  • Por qué las pruebas deben acompañar cada extracción.

11.18 Conclusión

En este tema eliminamos duplicación con cuidado. Extraer código común puede mejorar mucho el mantenimiento, pero solo cuando la abstracción representa una idea real. Si unificamos diferencias por apresurarnos, el diseño puede volverse más rígido y menos claro.

En el próximo tema refactorizaremos parámetros: objetos de datos, valores por defecto y firmas más simples.