26. Calidad en módulos de negocio: separar reglas, datos y presentación

26.1 Objetivo del tema

Los módulos de negocio contienen las reglas importantes del sistema: descuentos, impuestos, validaciones, cálculos y decisiones del dominio. Si esas reglas se mezclan con entrada, salida, archivos o presentación, el código se vuelve más difícil de probar y modificar.

En este tema veremos cómo separar datos, reglas de negocio y presentación en módulos Python simples, sin caer en una arquitectura innecesariamente compleja.

Objetivo práctico: reorganizar un módulo Python para que las reglas de negocio queden separadas de datos externos y salida de presentación.

26.2 Qué es lógica de negocio

La lógica de negocio representa decisiones propias del problema que estamos resolviendo. En un sistema de ventas, por ejemplo:

  • Cómo se calcula un descuento.
  • Qué impuesto corresponde a cada país.
  • Cuándo se cobra envío.
  • Qué datos hacen inválido un producto.
  • Cómo se calcula el total final.

Estas reglas deben poder probarse sin depender de la consola, archivos o una interfaz gráfica.

26.3 Qué no debería mezclarse con negocio

Conviene evitar que una función de negocio haga también:

  • Lectura directa de archivos.
  • Impresión por consola.
  • Construcción de HTML o texto de reporte.
  • Parseo de argumentos de terminal.
  • Configuración de logging.
  • Peticiones HTTP o acceso a bases de datos.

26.4 Ejemplo mezclado

Este código mezcla lectura, cálculo y presentación:

def procesar_venta(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        productos = []
        for linea in archivo:
            precio, cantidad = linea.strip().split(",")
            productos.append({"precio": float(precio), "cantidad": int(cantidad)})

    total = 0
    for producto in productos:
        total += producto["precio"] * producto["cantidad"]

    if total < 10000:
        total += 1500

    print(f"Total final: {total:.2f}")

Para probar el cálculo, necesitamos un archivo y debemos capturar salida. La regla de negocio quedó atrapada entre detalles externos.

26.5 Separar lectura

Primero separamos la lectura y el parseo:

def parsear_producto(linea: str) -> dict[str, float | int]:
    precio, cantidad = linea.strip().split(",")
    return {"precio": float(precio), "cantidad": int(cantidad)}


def leer_productos(ruta: str):
    with open(ruta, encoding="utf-8") as archivo:
        return [parsear_producto(linea) for linea in archivo]

Ahora la lectura queda en un lugar específico.

26.6 Separar reglas de negocio

El cálculo puede ser una función independiente:

LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def calcular_subtotal(productos):
    return sum(
        producto["precio"] * producto["cantidad"]
        for producto in productos
    )


def aplicar_envio(total):
    if total < LIMITE_ENVIO_GRATIS:
        return total + COSTO_ENVIO
    return total


def calcular_total(productos):
    subtotal = calcular_subtotal(productos)
    return aplicar_envio(subtotal)

Estas funciones ya no dependen de archivos ni consola.

26.7 Separar presentación

La salida puede vivir en otra función:

def formatear_total(total):
    return f"Total final: {total:.2f}"


def mostrar_total(total):
    print(formatear_total(total))

Podemos probar formatear_total sin imprimir.

26.8 Orquestar sin mezclar

Una función principal puede coordinar las partes:

def procesar_venta(ruta):
    productos = leer_productos(ruta)
    total = calcular_total(productos)
    mostrar_total(total)

Esta función orquesta. No contiene detalles de cálculo, parseo ni formato interno.

26.9 Organización de módulos

Una estructura simple puede ser:

src/
|-- modelos.py
|-- ventas.py
|-- entrada.py
|-- presentacion.py
`-- ventas_cli.py
  • modelos.py: dataclasses o estructuras de datos.
  • ventas.py: reglas de negocio.
  • entrada.py: lectura y parseo de datos externos.
  • presentacion.py: formato de salida.
  • ventas_cli.py: punto de entrada del script.

26.10 Modelos de datos

En modelos.py podemos tener:

from dataclasses import dataclass


@dataclass(frozen=True)
class Producto:
    precio: float
    cantidad: int


@dataclass(frozen=True)
class Venta:
    productos: list[Producto]
    tipo_cliente: str
    pais: str

Estos modelos no leen archivos ni imprimen. Representan datos del dominio.

26.11 Reglas de negocio

En ventas.py podemos tener:

from modelos import Producto, Venta

DESCUENTOS = {"vip": 0.15, "regular": 0.05}
IMPUESTOS = {"AR": 0.21, "UY": 0.22}


def calcular_subtotal(productos: list[Producto]) -> float:
    return sum(producto.precio * producto.cantidad for producto in productos)


def obtener_descuento(tipo_cliente: str) -> float:
    return DESCUENTOS.get(tipo_cliente, 0)


def obtener_impuesto(pais: str) -> float:
    return IMPUESTOS.get(pais, 0.19)


def calcular_total_venta(venta: Venta) -> float:
    subtotal = calcular_subtotal(venta.productos)
    descuento = obtener_descuento(venta.tipo_cliente)
    impuesto = obtener_impuesto(venta.pais)
    return round(subtotal * (1 - descuento) * (1 + impuesto), 2)

26.12 Entrada externa

En entrada.py podemos convertir texto en modelos:

from modelos import Producto


def parsear_producto(linea: str) -> Producto:
    precio, cantidad = linea.strip().split(",")
    return Producto(precio=float(precio), cantidad=int(cantidad))


def leer_productos(ruta: str) -> list[Producto]:
    with open(ruta, encoding="utf-8") as archivo:
        return [parsear_producto(linea) for linea in archivo]

Si cambia el formato del archivo, el cambio se concentra aquí.

26.13 Presentación

En presentacion.py podemos formatear salida:

def formatear_total(total: float) -> str:
    return f"Total final: {total:.2f}"

Esta función no calcula reglas de negocio. Solo decide cómo mostrar el resultado.

26.14 Punto de entrada

En ventas_cli.py coordinamos:

from entrada import leer_productos
from modelos import Venta
from presentacion import formatear_total
from ventas import calcular_total_venta


def main() -> int:
    productos = leer_productos("productos.txt")
    venta = Venta(productos=productos, tipo_cliente="vip", pais="AR")
    total = calcular_total_venta(venta)
    print(formatear_total(total))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

El punto de entrada une las piezas, pero no contiene las reglas internas.

26.15 Pruebas enfocadas

La separación permite pruebas más simples:

from modelos import Producto, Venta
from ventas import calcular_total_venta


def test_calcular_total_venta():
    venta = Venta(
        productos=[Producto(precio=1000, cantidad=2)],
        tipo_cliente="nuevo",
        pais="AR",
    )

    assert calcular_total_venta(venta) == 2420.0

Esta prueba no necesita archivos ni consola.

26.16 Señales de mala separación

Revisa tu código si encuentras:

  • Funciones de cálculo con print.
  • Funciones de negocio que abren archivos.
  • Funciones de presentación que calculan descuentos.
  • Modelos de datos que conocen detalles de consola o archivos.
  • Pruebas de reglas que necesitan preparar archivos externos.

26.17 Ejercicio guiado

Separa este código en tres responsabilidades: entrada, negocio y presentación.

def ejecutar():
    datos = input("Precio y cantidad: ")
    precio, cantidad = datos.split(",")
    total = float(precio) * int(cantidad)
    if total < 10000:
        total += 1500
    print(f"Total: {total}")

Una solución debería tener una función para parsear entrada, otra para calcular y otra para formatear salida.

26.18 Ejercicio propuesto

En ventas_demo, realiza estas tareas:

  • Crea o revisa módulos para modelos, negocio y presentación.
  • Mueve lectura de archivos fuera de las reglas de negocio.
  • Mueve print fuera de las funciones de cálculo.
  • Agrega pruebas enfocadas para reglas de negocio.
  • Ejecuta herramientas y pruebas.
python -m ruff check src tests
python -m black src tests
python -m pytest

26.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Distinguir reglas de negocio de entrada y presentación.
  • Crear módulos con responsabilidades claras.
  • Mantener modelos de datos sin efectos de consola o archivos.
  • Probar reglas sin depender de entrada externa.
  • Usar un punto de entrada para coordinar el flujo.
  • Evitar arquitecturas innecesariamente complejas para proyectos pequeños.

26.20 Conclusión

En este tema vimos cómo separar reglas, datos y presentación en módulos Python simples. Esta separación mejora la cohesión, reduce acoplamiento y facilita pruebas enfocadas.

En el próximo tema construiremos una checklist práctica para revisión de código y detección de code smells.