14. Mover responsabilidades entre funciones, clases y módulos

14.1 Objetivo del tema

A medida que un proyecto crece, algunas funciones y módulos empiezan a saber demasiado. Calculan reglas de negocio, formatean mensajes, escriben archivos, validan datos y coordinan flujo, todo en el mismo lugar. Esa mezcla dificulta cambiar una parte sin afectar otras.

En este tema practicaremos cómo mover responsabilidades entre funciones, clases y módulos. Separaremos cálculo, presentación y persistencia simulada, manteniendo pruebas durante cada paso.

Objetivo práctico: reorganizar código Python mezclado en piezas con responsabilidades claras sin cambiar el comportamiento visible.

14.2 Qué significa mover una responsabilidad

Mover una responsabilidad no es solamente mover líneas de archivo. Es ubicar una decisión en el lugar donde tiene más sentido. Una regla de descuento pertenece al dominio de ventas; un texto para mostrar al usuario pertenece a presentación; escribir en un archivo pertenece a persistencia o infraestructura.

Cuando una responsabilidad está en el lugar correcto, el código cambia menos ante una modificación. Si cambia el formato del mensaje, no deberíamos tocar la regla de cálculo del total.

14.3 Código inicial

Crea el archivo src/ventas_app.py:

def procesar_venta(venta, historial):
    total = 0
    for item in venta["items"]:
        total = total + item["precio"] * item["cantidad"]

    if venta["cliente"]["tipo"] == "vip":
        total = total * 0.9

    if venta["canal"] == "online":
        total = total + 500

    resultado = {
        "numero": venta["numero"],
        "cliente": venta["cliente"]["nombre"],
        "total": round(total, 2),
    }

    mensaje = "Venta " + venta["numero"]
    mensaje = mensaje + " | Cliente: " + venta["cliente"]["nombre"]
    mensaje = mensaje + " | Total: " + str(round(total, 2))

    historial.append(resultado)

    return mensaje

Esta función calcula, arma datos de resultado, formatea un mensaje y modifica el historial.

14.4 Pruebas de caracterización

Crea tests/test_ventas_app.py:

from ventas_app import procesar_venta


def test_procesa_venta_vip_online_y_actualiza_historial():
    historial = []
    venta = {
        "numero": "V-100",
        "canal": "online",
        "cliente": {"nombre": "Ana", "tipo": "vip"},
        "items": [
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
    }

    mensaje = procesar_venta(venta, historial)

    assert mensaje == "Venta V-100 | Cliente: Ana | Total: 2750.0"
    assert historial == [
        {"numero": "V-100", "cliente": "Ana", "total": 2750.0},
    ]

Ejecuta:

python -m pytest tests/test_ventas_app.py

14.5 Identificar responsabilidades mezcladas

En procesar_venta podemos separar cuatro responsabilidades:

  • Calcular el total de la venta.
  • Construir el resultado que se guarda en historial.
  • Formatear el mensaje para mostrar.
  • Registrar el resultado en el historial.

El primer paso será extraer funciones dentro del mismo módulo. Luego moveremos esas funciones a módulos más específicos.

14.6 Extraer cálculo de total

Primero extraemos la regla de cálculo:

def calcular_total_venta(venta):
    total = 0
    for item in venta["items"]:
        total = total + item["precio"] * item["cantidad"]

    if venta["cliente"]["tipo"] == "vip":
        total = total * 0.9

    if venta["canal"] == "online":
        total = total + 500

    return round(total, 2)

La función principal pasa a usarla:

def procesar_venta(venta, historial):
    total = calcular_total_venta(venta)

    resultado = {
        "numero": venta["numero"],
        "cliente": venta["cliente"]["nombre"],
        "total": total,
    }

    mensaje = "Venta " + venta["numero"]
    mensaje = mensaje + " | Cliente: " + venta["cliente"]["nombre"]
    mensaje = mensaje + " | Total: " + str(total)

    historial.append(resultado)

    return mensaje

Ejecuta python -m pytest tests/test_ventas_app.py.

14.7 Extraer construcción de resultado

La estructura que se guarda en historial también puede tener nombre:

def crear_resultado_venta(venta, total):
    return {
        "numero": venta["numero"],
        "cliente": venta["cliente"]["nombre"],
        "total": total,
    }

Ahora la función principal queda más simple:

resultado = crear_resultado_venta(venta, total)

14.8 Extraer formato de mensaje

El mensaje pertenece a presentación. Lo extraemos:

def formatear_mensaje_venta(resultado):
    mensaje = "Venta " + resultado["numero"]
    mensaje = mensaje + " | Cliente: " + resultado["cliente"]
    mensaje = mensaje + " | Total: " + str(resultado["total"])
    return mensaje

La función principal usa el resultado ya construido:

mensaje = formatear_mensaje_venta(resultado)

Después de cada extracción, ejecuta las pruebas.

14.9 Extraer registro en historial

La modificación del historial puede expresarse como una acción:

def registrar_venta(historial, resultado):
    historial.append(resultado)

La función procesar_venta queda como coordinadora:

def procesar_venta(venta, historial):
    total = calcular_total_venta(venta)
    resultado = crear_resultado_venta(venta, total)
    mensaje = formatear_mensaje_venta(resultado)
    registrar_venta(historial, resultado)
    return mensaje

Esta versión todavía está en un solo archivo, pero ya separa responsabilidades.

14.10 Mover a módulos separados

Cuando las funciones tienen responsabilidades claras, podemos moverlas a módulos. Una estructura posible es:

src/
  ventas_app.py
  ventas_dominio.py
  ventas_presentacion.py
  ventas_repositorio.py

La regla de cálculo puede ir a ventas_dominio.py, el formato a ventas_presentacion.py y el registro en historial a ventas_repositorio.py.

14.11 Módulo de dominio

Crea src/ventas_dominio.py:

def calcular_total_venta(venta):
    total = 0
    for item in venta["items"]:
        total = total + item["precio"] * item["cantidad"]

    if venta["cliente"]["tipo"] == "vip":
        total = total * 0.9

    if venta["canal"] == "online":
        total = total + 500

    return round(total, 2)


def crear_resultado_venta(venta, total):
    return {
        "numero": venta["numero"],
        "cliente": venta["cliente"]["nombre"],
        "total": total,
    }

Este módulo no sabe cómo se muestra ni dónde se guarda la venta.

14.12 Módulos de presentación y repositorio

Crea src/ventas_presentacion.py:

def formatear_mensaje_venta(resultado):
    mensaje = "Venta " + resultado["numero"]
    mensaje = mensaje + " | Cliente: " + resultado["cliente"]
    mensaje = mensaje + " | Total: " + str(resultado["total"])
    return mensaje

Crea src/ventas_repositorio.py:

def registrar_venta(historial, resultado):
    historial.append(resultado)

14.13 Coordinador de aplicación

Ahora src/ventas_app.py coordina las piezas:

from ventas_dominio import calcular_total_venta, crear_resultado_venta
from ventas_presentacion import formatear_mensaje_venta
from ventas_repositorio import registrar_venta


def procesar_venta(venta, historial):
    total = calcular_total_venta(venta)
    resultado = crear_resultado_venta(venta, total)
    mensaje = formatear_mensaje_venta(resultado)
    registrar_venta(historial, resultado)
    return mensaje

Ejecuta python -m pytest tests/test_ventas_app.py. Si las pruebas pasan, el movimiento entre módulos conservó el comportamiento.

14.14 Probar responsabilidades por separado

Ahora podemos probar el dominio sin depender del formato del mensaje:

from ventas_dominio import calcular_total_venta


def test_calcula_total_venta_vip_online():
    venta = {
        "numero": "V-100",
        "canal": "online",
        "cliente": {"nombre": "Ana", "tipo": "vip"},
        "items": [
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
    }

    assert calcular_total_venta(venta) == 2750.0

Separar responsabilidades también simplifica las pruebas.

14.15 Cuándo crear una clase

No todo grupo de funciones necesita una clase. Una clase tiene sentido cuando hay estado propio, invariantes o comportamiento que pertenece naturalmente a una entidad.

Por ejemplo, si el historial deja de ser una lista y necesita validar duplicados, buscar ventas y persistir datos, podríamos crear una clase HistorialVentas. Mientras solo hace append, una función es suficiente.

14.16 Ejercicio propuesto

Crea el archivo src/inscripciones_app.py:

def procesar_inscripcion(inscripcion, registros):
    total = inscripcion["curso"]["precio"]
    if inscripcion["alumno"]["tipo"] == "becado":
        total = total * 0.5

    registro = {
        "alumno": inscripcion["alumno"]["nombre"],
        "curso": inscripcion["curso"]["nombre"],
        "total": round(total, 2),
    }

    mensaje = "Inscripción de " + registro["alumno"]
    mensaje = mensaje + " al curso " + registro["curso"]
    mensaje = mensaje + " por " + str(registro["total"])

    registros.append(registro)

    return mensaje

Realiza estas tareas:

  • Escribe una prueba de caracterización.
  • Extrae el cálculo del total.
  • Extrae la creación del registro.
  • Extrae el formato del mensaje.
  • Mueve cada responsabilidad a módulos separados si la separación mejora la lectura.
  • Ejecuta python -m pytest después de cada paso.

14.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Cómo detectar responsabilidades mezcladas en una función.
  • Qué diferencia hay entre dominio, presentación y persistencia.
  • Por qué conviene extraer funciones antes de moverlas a otro módulo.
  • Cómo mantener pruebas mientras se mueven funciones entre archivos.
  • Cuándo una función alcanza y cuándo una clase puede tener sentido.
  • Por qué un módulo coordinador no debería contener todas las reglas.

14.18 Conclusión

En este tema movimos responsabilidades entre funciones y módulos para separar cálculo, presentación y registro de datos. El objetivo no fue crear más archivos por costumbre, sino ubicar cada decisión donde sea más fácil entenderla y cambiarla.

En el próximo tema extraeremos clases desde datos y comportamiento mezclados.