En Python, un proyecto suele crecer como un conjunto de módulos. Si cada módulo conoce demasiados detalles de los demás, cualquier cambio se vuelve riesgoso. A ese problema lo llamamos acoplamiento alto.
En este tema veremos cómo reconocer módulos acoplados, cómo mejorar la cohesión y cómo separar responsabilidades sin crear una arquitectura innecesariamente compleja.
El acoplamiento describe cuánto depende una parte del sistema de los detalles internos de otra. Si un módulo necesita conocer estructuras internas, rutas, formatos y decisiones de otro módulo, el acoplamiento es alto.
Un acoplamiento razonable es inevitable: los módulos deben colaborar. El problema aparece cuando una modificación pequeña en un módulo obliga a cambiar muchos otros.
La cohesión describe qué tan relacionadas están las responsabilidades dentro de un módulo. Un módulo cohesivo agrupa funciones que trabajan sobre una misma idea del dominio.
Por ejemplo, un módulo impuestos.py que contiene reglas de impuestos tiene buena cohesión. Un módulo utilidades.py con impuestos, archivos, fechas, emails y descuentos probablemente tenga baja cohesión.
Sospecha de acoplamiento alto cuando:
Supón que reportes.py conoce todos los detalles de productos, impuestos, descuentos y formato de salida:
def generar_reporte_venta(productos, cliente, pais):
subtotal = 0
for producto in productos:
subtotal += producto["precio"] * producto["cantidad"]
if cliente["tipo"] == "vip":
subtotal -= subtotal * 0.15
if pais == "AR":
subtotal += subtotal * 0.21
return f"Cliente: {cliente['email']} - Total: {round(subtotal, 2)}"
El módulo de reportes no solo genera un reporte. También calcula reglas de venta y conoce la estructura interna del cliente.
Una mejora es mover el cálculo al módulo que corresponde y dejar el reporte como presentación.
def generar_reporte_venta(cliente, total):
return f"Cliente: {cliente['email']} - Total: {total}"
Y en otro módulo:
def calcular_total_venta(productos, tipo_cliente, pais):
subtotal = calcular_subtotal(productos)
descuento = obtener_descuento(tipo_cliente)
impuesto = obtener_impuesto(pais)
return round(subtotal * (1 - descuento) * (1 + impuesto), 2)
Ahora cada módulo sabe menos del otro. El reporte recibe un total ya calculado.
El proyecto ventas_demo podría organizarse así:
src/
|-- ventas.py
|-- impuestos.py
|-- descuentos.py
|-- reportes.py
`-- validaciones.py
Cada módulo tiene una responsabilidad principal:
ventas.py: coordina el cálculo de una venta.impuestos.py: reglas de impuestos.descuentos.py: reglas de descuentos.reportes.py: salida textual o estructura de reporte.validaciones.py: validación de datos de entrada.Podemos mover reglas de descuento a src/descuentos.py:
DESCUENTOS = {
"vip": 0.15,
"regular": 0.05,
}
def obtener_descuento(tipo_cliente):
return DESCUENTOS.get(tipo_cliente, 0)
El resto del sistema ya no necesita saber cómo se decide el descuento. Solo llama a obtener_descuento.
Podemos crear src/impuestos.py:
IMPUESTOS = {
"AR": 0.21,
"UY": 0.22,
}
IMPUESTO_PREDETERMINADO = 0.19
def obtener_impuesto(pais):
return IMPUESTOS.get(pais, IMPUESTO_PREDETERMINADO)
Si cambia una tasa o se agrega un país, el cambio queda concentrado.
Las validaciones de productos pueden vivir en src/validaciones.py:
def validar_producto(producto):
if "precio" not in producto:
raise ValueError("Falta el precio")
if "cantidad" not in producto:
raise ValueError("Falta la cantidad")
if producto["precio"] < 0:
raise ValueError("El precio no puede ser negativo")
if producto["cantidad"] <= 0:
raise ValueError("La cantidad debe ser positiva")
Separar validación permite probar errores de entrada sin revisar todo el cálculo de venta.
El archivo src/ventas.py puede coordinar las reglas sin contener todos los detalles:
from descuentos import obtener_descuento
from impuestos import obtener_impuesto
from validaciones import validar_producto
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500
def calcular_subtotal(productos):
subtotal = 0
for producto in productos:
validar_producto(producto)
subtotal += producto["precio"] * producto["cantidad"]
return subtotal
def aplicar_envio(total):
if total < LIMITE_ENVIO_GRATIS:
return total + COSTO_ENVIO
return total
def calcular_total_venta(productos, tipo_cliente, pais):
subtotal = calcular_subtotal(productos)
descuento = obtener_descuento(tipo_cliente)
impuesto = obtener_impuesto(pais)
total = subtotal * (1 - descuento)
total = total * (1 + impuesto)
total = aplicar_envio(total)
return round(total, 2)
El módulo sigue coordinando, pero delega reglas específicas.
Un archivo llamado utils.py puede ser útil al principio, pero muchas veces se convierte en un depósito de funciones sin relación.
# utils.py
def obtener_impuesto(pais):
...
def validar_email(email):
...
def guardar_archivo(ruta, contenido):
...
def calcular_descuento(cliente):
...
Si un módulo mezcla temas distintos, su cohesión es baja. Conviene mover funciones a módulos con nombres de dominio.
Un import circular ocurre cuando dos módulos se importan mutuamente. Suele indicar que las responsabilidades no están bien separadas.
# ventas.py
from reportes import generar_reporte
# reportes.py
from ventas import calcular_total_venta
Una forma de romper el ciclo es que un tercer módulo coordine el flujo, o que reportes.py reciba datos ya calculados en lugar de importar la lógica de ventas.
La lógica de negocio no debería depender directamente de archivos, consola o red si puede evitarse.
def calcular_total_desde_archivo(ruta):
with open(ruta, encoding="utf-8") as archivo:
productos = cargar_productos(archivo)
return calcular_total_venta(productos, "vip", "AR")
Es mejor separar lectura y cálculo:
def calcular_total_venta(productos, tipo_cliente, pais):
...
def calcular_total_desde_archivo(ruta):
productos = leer_productos(ruta)
return calcular_total_venta(productos, "vip", "AR")
Cuando las reglas están separadas, las pruebas pueden ser más enfocadas.
from descuentos import obtener_descuento
from impuestos import obtener_impuesto
def test_obtener_descuento_cliente_vip():
assert obtener_descuento("vip") == 0.15
def test_obtener_impuesto_pais_desconocido():
assert obtener_impuesto("BR") == 0.19
Estas pruebas no necesitan crear productos, archivos ni reportes. Solo verifican una regla concreta.
Reorganiza este archivo único en módulos con responsabilidades claras:
def validar_producto(producto):
if producto["cantidad"] <= 0:
raise ValueError("Cantidad inválida")
def obtener_impuesto(pais):
if pais == "AR":
return 0.21
return 0.19
def calcular_total(productos, pais):
total = 0
for producto in productos:
validar_producto(producto)
total += producto["precio"] * producto["cantidad"]
return total * (1 + obtener_impuesto(pais))
def generar_reporte(total):
return f"Total: {total}"
Una posible división es: validaciones.py, impuestos.py, ventas.py y reportes.py.
En ventas_demo, realiza estas tareas:
descuentos.py o impuestos.py.python -m ruff check src tests
python -m black src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
utils.py con responsabilidades mezcladas.En este tema vimos que el acoplamiento alto hace que los cambios se propaguen por el proyecto, mientras que la cohesión ayuda a ubicar cada regla donde corresponde. Separar módulos no significa complicar la arquitectura; significa dar nombres claros a responsabilidades reales.
En el próximo tema estudiaremos datos globales, efectos secundarios y funciones difíciles de probar.