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.
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.
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.
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
En procesar_venta podemos separar cuatro responsabilidades:
El primer paso será extraer funciones dentro del mismo módulo. Luego moveremos esas funciones a módulos más específicos.
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.
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)
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.
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.
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.
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.
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)
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.
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.
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.
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:
python -m pytest después de cada paso.Antes de continuar, verifica que puedes explicar estos puntos:
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.