16. Acoplamiento y cohesión: módulos que saben demasiado de otros módulos

16.1 Objetivo del tema

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.

Objetivo práctico: detectar dependencias incómodas entre módulos Python y reorganizar código para que cada módulo tenga una responsabilidad clara.

16.2 Qué es acoplamiento

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.

16.3 Qué es cohesión

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.

16.4 Señales de acoplamiento alto

Sospecha de acoplamiento alto cuando:

  • Un módulo importa muchas cosas de otro módulo.
  • Una función conoce claves internas de diccionarios creados en otro lugar.
  • Un cambio de formato rompe varias partes del sistema.
  • La lógica de negocio depende directamente de archivos, red o consola.
  • Los tests necesitan preparar demasiados detalles internos.
  • Aparecen imports circulares.

16.5 Ejemplo de módulo que sabe demasiado

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.

16.6 Separar cálculo y presentación

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.

16.7 Organizar ventas_demo por responsabilidades

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.

16.8 Módulo de descuentos

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.

16.9 Módulo de impuestos

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.

16.10 Módulo de validaciones

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.

16.11 Módulo principal de ventas

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.

16.12 Cuidado con módulos utilidades

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.

16.13 Imports circulares

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.

16.14 Dependencias hacia detalles externos

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")

16.15 Pruebas más simples con bajo acoplamiento

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.

16.16 Ejercicio guiado

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.

16.17 Ejercicio propuesto

En ventas_demo, realiza estas tareas:

  • Identifica funciones que pertenecen a reglas distintas.
  • Extrae un módulo descuentos.py o impuestos.py.
  • Actualiza imports sin crear ciclos.
  • Agrega pruebas para el módulo extraído.
  • Ejecuta herramientas y pruebas.
python -m ruff check src tests
python -m black src tests
python -m pytest

16.18 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Explicar acoplamiento y cohesión con tus palabras.
  • Reconocer módulos que saben demasiado de otros módulos.
  • Separar reglas de negocio, validación y presentación.
  • Evitar módulos utils.py con responsabilidades mezcladas.
  • Detectar y evitar imports circulares.
  • Escribir pruebas enfocadas para módulos pequeños.
  • Reorganizar código sin cambiar comportamiento.

16.19 Conclusión

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.