16. Refactorizar clases grandes hacia responsabilidades pequeñas

16.1 Objetivo del tema

Una clase grande suele empezar como una solución cómoda: todo está en un solo lugar. Con el tiempo acumula cálculo, validación, formato, persistencia, configuración y coordinación. El resultado es una clase difícil de probar y de modificar.

En este tema aprenderemos a dividir una clase grande en responsabilidades pequeñas. Usaremos composición y una fachada para mantener una interfaz estable mientras movemos comportamiento a clases más específicas.

Objetivo práctico: refactorizar una clase Python grande en piezas pequeñas y cohesivas, conservando el comportamiento con pruebas.

16.2 Señales de una clase demasiado grande

Una clase puede estar acumulando demasiadas responsabilidades cuando:

  • Tiene métodos que cambian por motivos distintos.
  • Algunos métodos usan un grupo de atributos y otros métodos usan otro grupo distinto.
  • Mezcla reglas de negocio con formato de salida o almacenamiento.
  • Sus pruebas requieren preparar demasiados datos no relacionados.
  • Cada cambio pequeño obliga a revisar toda la clase.

16.3 Código inicial

Crea el archivo src/servicio_pedidos.py:

class ServicioPedidos:
    def __init__(self):
        self.historial = []

    def calcular_total(self, pedido):
        total = 0
        for item in pedido["items"]:
            total = total + item["precio"] * item["cantidad"]

        if pedido["cliente"]["tipo"] == "vip":
            total = total * 0.9

        if pedido["canal"] == "online":
            total = total + 500

        return round(total, 2)

    def crear_resumen(self, pedido):
        total = self.calcular_total(pedido)
        return {
            "numero": pedido["numero"],
            "cliente": pedido["cliente"]["nombre"],
            "total": total,
        }

    def formatear_mensaje(self, resumen):
        return (
            "Pedido "
            + resumen["numero"]
            + " | Cliente: "
            + resumen["cliente"]
            + " | Total: "
            + str(resumen["total"])
        )

    def guardar(self, resumen):
        self.historial.append(resumen)

    def procesar(self, pedido):
        resumen = self.crear_resumen(pedido)
        self.guardar(resumen)
        return self.formatear_mensaje(resumen)

La clase calcula, crea resumen, formatea mensajes, guarda historial y coordina el flujo.

16.4 Pruebas de caracterización

Crea tests/test_servicio_pedidos.py:

from servicio_pedidos import ServicioPedidos


def test_procesa_pedido_vip_online_y_guarda_historial():
    servicio = ServicioPedidos()
    pedido = {
        "numero": "P-100",
        "canal": "online",
        "cliente": {"nombre": "Ana", "tipo": "vip"},
        "items": [
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
    }

    mensaje = servicio.procesar(pedido)

    assert mensaje == "Pedido P-100 | Cliente: Ana | Total: 2750.0"
    assert servicio.historial == [
        {"numero": "P-100", "cliente": "Ana", "total": 2750.0},
    ]

Ejecuta:

python -m pytest tests/test_servicio_pedidos.py

16.5 Agrupar métodos por responsabilidad

Antes de extraer clases, agrupamos mentalmente los métodos:

  • calcular_total: regla de negocio.
  • crear_resumen: armado de datos de salida.
  • formatear_mensaje: presentación.
  • guardar y historial: almacenamiento en memoria.
  • procesar: coordinación.

Esta clasificación orienta qué clases pequeñas podrían existir.

16.6 Extraer calculadora de pedidos

Primero movemos el cálculo a una clase específica:

class CalculadoraPedido:
    def calcular_total(self, pedido):
        total = 0
        for item in pedido["items"]:
            total = total + item["precio"] * item["cantidad"]

        if pedido["cliente"]["tipo"] == "vip":
            total = total * 0.9

        if pedido["canal"] == "online":
            total = total + 500

        return round(total, 2)

La clase original puede delegar:

class ServicioPedidos:
    def __init__(self):
        self.historial = []
        self.calculadora = CalculadoraPedido()

    def calcular_total(self, pedido):
        return self.calculadora.calcular_total(pedido)

Ejecuta las pruebas después del cambio.

16.7 Extraer formateador

El formato del mensaje puede moverse a otra clase:

class FormateadorPedido:
    def formatear_mensaje(self, resumen):
        return (
            "Pedido "
            + resumen["numero"]
            + " | Cliente: "
            + resumen["cliente"]
            + " | Total: "
            + str(resumen["total"])
        )

La clase original delega:

def formatear_mensaje(self, resumen):
    return self.formateador.formatear_mensaje(resumen)

Si mañana cambia el formato, la regla de cálculo no se toca.

16.8 Extraer repositorio en memoria

El historial representa almacenamiento en memoria. Podemos darle una clase propia:

class RepositorioPedidos:
    def __init__(self):
        self.historial = []

    def guardar(self, resumen):
        self.historial.append(resumen)

Para mantener compatibilidad con la prueba anterior, la fachada puede exponer una propiedad historial.

16.9 Mantener una fachada compatible

ServicioPedidos puede quedar como coordinador de las piezas:

class ServicioPedidos:
    def __init__(self):
        self.calculadora = CalculadoraPedido()
        self.formateador = FormateadorPedido()
        self.repositorio = RepositorioPedidos()

    @property
    def historial(self):
        return self.repositorio.historial

    def calcular_total(self, pedido):
        return self.calculadora.calcular_total(pedido)

    def crear_resumen(self, pedido):
        total = self.calcular_total(pedido)
        return {
            "numero": pedido["numero"],
            "cliente": pedido["cliente"]["nombre"],
            "total": total,
        }

    def formatear_mensaje(self, resumen):
        return self.formateador.formatear_mensaje(resumen)

    def guardar(self, resumen):
        self.repositorio.guardar(resumen)

    def procesar(self, pedido):
        resumen = self.crear_resumen(pedido)
        self.guardar(resumen)
        return self.formatear_mensaje(resumen)

La interfaz pública se conserva, pero internamente hay responsabilidades separadas.

16.10 Extraer creador de resumen

Si la creación del resumen empieza a crecer, también puede moverse:

class CreadorResumenPedido:
    def __init__(self, calculadora):
        self.calculadora = calculadora

    def crear_resumen(self, pedido):
        total = self.calculadora.calcular_total(pedido)
        return {
            "numero": pedido["numero"],
            "cliente": pedido["cliente"]["nombre"],
            "total": total,
        }

No siempre hace falta llegar a este nivel. Se justifica cuando el resumen tiene reglas propias o cambia por motivos distintos.

16.11 Código refactorizado completo

Una versión razonable puede ser:

class CalculadoraPedido:
    def calcular_total(self, pedido):
        total = 0
        for item in pedido["items"]:
            total = total + item["precio"] * item["cantidad"]

        if pedido["cliente"]["tipo"] == "vip":
            total = total * 0.9

        if pedido["canal"] == "online":
            total = total + 500

        return round(total, 2)


class FormateadorPedido:
    def formatear_mensaje(self, resumen):
        return (
            "Pedido "
            + resumen["numero"]
            + " | Cliente: "
            + resumen["cliente"]
            + " | Total: "
            + str(resumen["total"])
        )


class RepositorioPedidos:
    def __init__(self):
        self.historial = []

    def guardar(self, resumen):
        self.historial.append(resumen)


class ServicioPedidos:
    def __init__(self):
        self.calculadora = CalculadoraPedido()
        self.formateador = FormateadorPedido()
        self.repositorio = RepositorioPedidos()

    @property
    def historial(self):
        return self.repositorio.historial

    def crear_resumen(self, pedido):
        total = self.calculadora.calcular_total(pedido)
        return {
            "numero": pedido["numero"],
            "cliente": pedido["cliente"]["nombre"],
            "total": total,
        }

    def procesar(self, pedido):
        resumen = self.crear_resumen(pedido)
        self.repositorio.guardar(resumen)
        return self.formateador.formatear_mensaje(resumen)

Ejecuta python -m pytest tests/test_servicio_pedidos.py para confirmar que el comportamiento se mantiene.

16.12 Probar clases pequeñas

Ahora podemos probar la calculadora sin historial ni formato:

from servicio_pedidos import CalculadoraPedido


def test_calculadora_pedido_vip_online():
    pedido = {
        "numero": "P-100",
        "canal": "online",
        "cliente": {"nombre": "Ana", "tipo": "vip"},
        "items": [
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
    }

    assert CalculadoraPedido().calcular_total(pedido) == 2750.0

Las pruebas pequeñas suelen ser más directas y más fáciles de diagnosticar.

16.13 Composición en lugar de herencia

En este ejemplo usamos composición: ServicioPedidos tiene una calculadora, un formateador y un repositorio. No usamos herencia.

La composición suele ser más flexible para refactoring porque permite reemplazar una parte sin crear jerarquías rígidas. La herencia tiene sentido cuando hay una relación clara de especialización, no solo para compartir código.

16.14 Cuándo detener la división

Dividir una clase grande no significa crear una clase por cada método. Conviene detenerse cuando cada pieza tiene una responsabilidad clara y el costo de navegar el código sigue siendo razonable.

Si una nueva clase solo tiene un método trivial y no representa un concepto, tal vez todavía no hace falta extraerla.

16.15 Ejercicio propuesto

Crea el archivo src/servicio_inscripciones.py:

class ServicioInscripciones:
    def __init__(self):
        self.registros = []

    def calcular_total(self, inscripcion):
        total = inscripcion["curso"]["precio"]
        if inscripcion["alumno"]["tipo"] == "becado":
            total = total * 0.5
        if inscripcion["modalidad"] == "presencial":
            total = total + 3000
        return round(total, 2)

    def crear_registro(self, inscripcion):
        return {
            "alumno": inscripcion["alumno"]["nombre"],
            "curso": inscripcion["curso"]["nombre"],
            "total": self.calcular_total(inscripcion),
        }

    def formatear_mensaje(self, registro):
        return registro["alumno"] + " inscripto en " + registro["curso"]

    def procesar(self, inscripcion):
        registro = self.crear_registro(inscripcion)
        self.registros.append(registro)
        return self.formatear_mensaje(registro)

Realiza estas tareas:

  • Escribe una prueba de caracterización para procesar.
  • Extrae una calculadora de inscripción.
  • Extrae un formateador de inscripción.
  • Extrae un repositorio en memoria.
  • Mantén una fachada compatible si las pruebas dependen de registros.
  • Ejecuta python -m pytest después de cada paso.

16.16 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué señales indican que una clase es demasiado grande.
  • Cómo agrupar métodos por responsabilidad.
  • Cómo extraer una clase pequeña sin romper la interfaz pública.
  • Qué papel cumple una fachada durante una migración.
  • Por qué la composición suele ser útil en este tipo de refactoring.
  • Cuándo conviene detener la división en clases más pequeñas.

16.17 Conclusión

En este tema dividimos una clase grande en responsabilidades pequeñas: cálculo, formato, almacenamiento y coordinación. La clase original quedó como una fachada más simple, mientras que las reglas específicas se movieron a piezas más cohesivas y fáciles de probar.

En el próximo tema usaremos dataclasses y type hints para aclarar estructuras de datos.