18. Diseño de clases: clases demasiado grandes, clases vacías y responsabilidades mezcladas

18.1 Objetivo del tema

Las clases pueden organizar muy bien un programa, pero también pueden convertirse en una fuente de code smells. Una clase demasiado grande concentra demasiadas responsabilidades. Una clase vacía agrega estructura sin valor. Una clase con responsabilidades mezcladas se vuelve difícil de probar y modificar.

En este tema veremos cuándo una clase ayuda, cuándo sobra y cómo dividir responsabilidades de manera práctica en Python.

Objetivo práctico: reconocer clases con mal diseño y reorganizarlas en estructuras más simples, cohesivas y fáciles de probar.

18.2 Una clase no siempre es necesaria

En Python no hace falta crear una clase para todo. Si solo necesitas calcular un valor a partir de parámetros, una función puede ser suficiente.

class Calculadora:
    def sumar(self, a, b):
        return a + b

En este caso, la clase no aporta estado ni comportamiento organizado. Una función es más simple:

def sumar(a, b):
    return a + b

18.3 Cuándo una clase sí ayuda

Una clase puede ser útil cuando agrupa datos y comportamiento relacionados, mantiene estado con sentido o representa un concepto del dominio.

class Carrito:
    def __init__(self):
        self.productos = []

    def agregar_producto(self, producto):
        self.productos.append(producto)

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

Esta clase tiene una responsabilidad reconocible: manejar productos de un carrito.

18.4 Smell: clase demasiado grande

Una clase demasiado grande suele tener métodos que pertenecen a conceptos distintos.

class SistemaVentas:
    def validar_producto(self, producto):
        ...

    def calcular_total(self, productos):
        ...

    def obtener_impuesto(self, pais):
        ...

    def generar_reporte(self, total):
        ...

    def guardar_archivo(self, ruta, contenido):
        ...

    def enviar_email(self, destino, mensaje):
        ...

Esta clase valida, calcula, genera reportes, escribe archivos y envía emails. Tiene demasiadas razones para cambiar.

18.5 Dividir por responsabilidades

Podemos separar responsabilidades en clases o funciones más pequeñas:

class CalculadoraVentas:
    def calcular_total(self, productos, tipo_cliente, pais):
        ...


class GeneradorReportes:
    def generar_reporte_venta(self, total):
        ...


class RepositorioArchivos:
    def guardar(self, ruta, contenido):
        ...

No se trata de crear muchas clases por costumbre. Se trata de ubicar responsabilidades que cambian por motivos distintos en lugares distintos.

18.6 Smell: clase vacía o anémica sin propósito

Una clase vacía o casi vacía puede ser ruido si no representa una idea útil.

class Producto:
    pass


producto = Producto()
producto.precio = 1000
producto.cantidad = 2

Esta clase no documenta qué datos tiene ni protege ninguna regla. Un diccionario o una dataclass serían más claros.

18.7 Usar dataclass para datos

Si una clase solo representa datos, dataclass puede ser una opción clara.

from dataclasses import dataclass


@dataclass
class Producto:
    precio: float
    cantidad: int

Ahora la estructura del dato es explícita. En el próximo tema profundizaremos más en dataclasses.

18.8 Smell: método que no usa self

Si un método no usa self, quizá no necesita estar dentro de la clase.

class CalculadoraVentas:
    def obtener_impuesto(self, pais):
        if pais == "AR":
            return 0.21
        return 0.19

Si no usa estado de la instancia, podría ser una función de módulo:

def obtener_impuesto(pais):
    if pais == "AR":
        return 0.21
    return 0.19

18.9 Smell: clase con datos y salida mezclados

Una clase de dominio no debería encargarse necesariamente de imprimir, guardar o enviar datos.

class Venta:
    def __init__(self, productos):
        self.productos = productos

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

    def imprimir_ticket(self):
        print(f"Total: {self.calcular_total()}")

El cálculo y la impresión están mezclados. Podemos separar presentación:

class Venta:
    def __init__(self, productos):
        self.productos = productos

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


def generar_ticket(venta):
    return f"Total: {venta.calcular_total()}"

18.10 Smell: clase que conoce demasiado de otra

Una clase está muy acoplada a otra cuando depende de muchos detalles internos.

class ReporteVenta:
    def generar(self, venta):
        total = venta.productos[0]["precio"] * venta.productos[0]["cantidad"]
        return f"Total parcial: {total}"

El reporte conoce la estructura interna de venta.productos. Es mejor pedirle a Venta lo que necesitamos:

class ReporteVenta:
    def generar(self, venta):
        return f"Total: {venta.calcular_total()}"

18.11 Aplicación sobre ventas_demo

El proyecto ventas_demo no necesita clases para todo. Muchas reglas pueden seguir como funciones. Pero si queremos representar una venta con datos y comportamiento, podríamos usar una clase pequeña:

class Venta:
    def __init__(self, productos, tipo_cliente, pais):
        self.productos = productos
        self.tipo_cliente = tipo_cliente
        self.pais = pais

    def calcular_total(self):
        return calcular_total_venta(
            self.productos,
            self.tipo_cliente,
            self.pais,
        )

La clase representa una venta concreta. No imprime, no guarda archivos y no envía emails.

18.12 Evitar clases con nombres demasiado generales

Nombres como Manager, Processor, Handler o Service pueden ser aceptables en algunos contextos, pero muchas veces ocultan responsabilidades mezcladas.

class VentaManager:
    def procesar(self):
        ...

Preguntas útiles:

  • ¿Qué responsabilidad concreta tiene?
  • ¿Qué datos propios mantiene?
  • ¿Por qué debería ser una clase y no una función?
  • ¿Cuántos motivos distintos tiene para cambiar?

18.13 Composición antes que clase gigante

Cuando una operación necesita colaborar con varias partes, puede recibir colaboradores en lugar de concentrarlo todo en una clase enorme.

class ProcesadorVentas:
    def __init__(self, calculadora, generador_reportes):
        self.calculadora = calculadora
        self.generador_reportes = generador_reportes

    def procesar(self, venta):
        total = self.calculadora.calcular(venta)
        return self.generador_reportes.generar(total)

Esta estructura tiene sentido si el proyecto realmente necesita esos colaboradores. En proyectos pequeños, funciones simples pueden ser mejores.

18.14 Pruebas para clases pequeñas

Una clase con responsabilidad clara es fácil de probar.

def test_venta_calcula_total():
    venta = Venta(
        productos=[
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
        tipo_cliente="nuevo",
        pais="AR",
    )

    assert venta.calcular_total() == 4525.0

Si para probar una clase necesitas crear muchas dependencias no relacionadas, quizá la clase está demasiado acoplada.

18.15 Cuándo preferir función

Prefiere una función cuando:

  • No necesitas mantener estado.
  • La operación recibe datos y devuelve un resultado.
  • No hay un concepto del dominio que agrupe datos y comportamiento.
  • La clase tendría un solo método útil y ningún dato propio.

18.16 Cuándo preferir clase

Prefiere una clase cuando:

  • Hay datos y comportamiento que pertenecen al mismo concepto.
  • El estado de la instancia es importante y está controlado.
  • Necesitas colaboradores intercambiables.
  • La clase reduce acoplamiento y mejora cohesión.

18.17 Ejercicio guiado

Analiza esta clase y separa responsabilidades:

class Pedido:
    def __init__(self, productos, email):
        self.productos = productos
        self.email = email

    def calcular_total(self):
        total = 0
        for producto in self.productos:
            total += producto["precio"] * producto["cantidad"]
        return total

    def guardar(self):
        with open("pedido.txt", "w", encoding="utf-8") as archivo:
            archivo.write(str(self.productos))

    def enviar_email(self):
        print(f"Enviando email a {self.email}")

La clase representa un pedido, calcula, guarda y envía email. Una mejora sería dejar el cálculo en Pedido y mover guardado/envío a funciones o clases separadas.

18.18 Ejercicio propuesto

En ventas_demo, realiza estas tareas:

  • Identifica si alguna clase sería útil o si las funciones actuales son suficientes.
  • Si creas una clase, dale una responsabilidad clara.
  • Evita mezclar cálculo con impresión o archivos.
  • Agrega pruebas para la clase o función resultante.
  • Ejecuta herramientas y pruebas.
python -m ruff check src tests
python -m black src tests
python -m pytest

18.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Reconocer clases demasiado grandes.
  • Detectar clases vacías o sin propósito claro.
  • Distinguir cuándo alcanza una función.
  • Usar clases para agrupar datos y comportamiento relacionados.
  • Evitar que una clase mezcle dominio, presentación y persistencia.
  • Probar clases pequeñas con responsabilidades claras.

18.20 Conclusión

En este tema vimos que una clase bien diseñada tiene una responsabilidad reconocible. Las clases gigantes, vacías o mezcladas aumentan acoplamiento y reducen claridad.

En el próximo tema veremos el uso práctico de dataclasses para representar datos con claridad.