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.
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
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.
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.
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.
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.
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.
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
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()}"
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()}"
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.
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:
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.
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.
Prefiere una función cuando:
Prefiere una clase cuando:
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.
En ventas_demo, realiza estas tareas:
python -m ruff check src tests
python -m black src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
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.