15. Extraer clases desde datos y comportamiento mezclados

15.1 Objetivo del tema

En muchos proyectos Python, los datos empiezan como diccionarios y las reglas se escriben como funciones sueltas. Eso puede ser suficiente al principio, pero cuando varias funciones reciben el mismo diccionario y conocen sus claves internas, aparece una oportunidad para extraer una clase.

En este tema transformaremos datos y comportamiento mezclados en una clase con responsabilidad clara. Lo haremos de manera gradual, manteniendo pruebas y una función adaptadora para no romper llamadas existentes.

Objetivo práctico: extraer una clase desde funciones que operan sobre la misma estructura de datos, conservando el comportamiento con pruebas.

15.2 Cuándo conviene extraer una clase

Extraer una clase puede ser útil cuando:

  • Varias funciones reciben el mismo diccionario o grupo de datos.
  • Las funciones repiten acceso a las mismas claves.
  • Hay reglas que pertenecen naturalmente a una entidad del dominio.
  • Queremos proteger invariantes o validar datos en un solo lugar.
  • El código usa muchos parámetros que siempre viajan juntos.

No hace falta crear clases para todo. Una clase debe mejorar la cohesión, no solo envolver funciones sin aportar claridad.

15.3 Código inicial

Crea el archivo src/pedidos_clase.py:

def calcular_subtotal(pedido):
    subtotal = 0
    for item in pedido["items"]:
        subtotal = subtotal + item["precio"] * item["cantidad"]
    return subtotal


def calcular_descuento(pedido):
    if pedido["cliente"]["tipo"] == "vip":
        return calcular_subtotal(pedido) * 0.10
    return 0


def calcular_envio(pedido):
    if pedido["retiro_en_local"]:
        return 0
    if calcular_subtotal(pedido) >= 10000:
        return 0
    return 1200


def calcular_total(pedido):
    subtotal = calcular_subtotal(pedido)
    descuento = calcular_descuento(pedido)
    envio = calcular_envio(pedido)
    return round(subtotal - descuento + envio, 2)

Las funciones comparten el mismo diccionario pedido y conocen sus claves internas.

15.4 Pruebas antes de extraer la clase

Crea tests/test_pedidos_clase.py:

from pedidos_clase import calcular_total


def test_calcula_total_pedido_vip_con_envio():
    pedido = {
        "cliente": {"nombre": "Ana", "tipo": "vip"},
        "retiro_en_local": False,
        "items": [
            {"precio": 3000, "cantidad": 2},
        ],
    }

    assert calcular_total(pedido) == 6600.0


def test_calcula_total_pedido_regular_con_envio_gratis():
    pedido = {
        "cliente": {"nombre": "Luis", "tipo": "regular"},
        "retiro_en_local": False,
        "items": [
            {"precio": 5000, "cantidad": 2},
        ],
    }

    assert calcular_total(pedido) == 10000

Ejecuta:

python -m pytest tests/test_pedidos_clase.py

15.5 Crear una clase mínima

El primer paso es crear una clase que reciba los mismos datos, sin mover toda la lógica todavía:

class Pedido:
    def __init__(self, datos):
        self.datos = datos

Esto por sí solo no mejora mucho. La clase empieza a tener sentido cuando movemos comportamiento relacionado con esos datos.

15.6 Mover calcular_subtotal a la clase

El subtotal usa solo los items del pedido. Es un buen primer método:

class Pedido:
    def __init__(self, datos):
        self.datos = datos

    def calcular_subtotal(self):
        subtotal = 0
        for item in self.datos["items"]:
            subtotal = subtotal + item["precio"] * item["cantidad"]
        return subtotal

Podemos actualizar la función externa para delegar:

def calcular_subtotal(pedido):
    return Pedido(pedido).calcular_subtotal()

Ejecuta las pruebas después de este cambio.

15.7 Mover descuento y envío

Ahora movemos reglas que también pertenecen al pedido:

class Pedido:
    def __init__(self, datos):
        self.datos = datos

    def calcular_subtotal(self):
        subtotal = 0
        for item in self.datos["items"]:
            subtotal = subtotal + item["precio"] * item["cantidad"]
        return subtotal

    def calcular_descuento(self):
        if self.datos["cliente"]["tipo"] == "vip":
            return self.calcular_subtotal() * 0.10
        return 0

    def calcular_envio(self):
        if self.datos["retiro_en_local"]:
            return 0
        if self.calcular_subtotal() >= 10000:
            return 0
        return 1200

El comportamiento se acerca al dato que usa.

15.8 Mover el cálculo total

La clase puede calcular su propio total:

class Pedido:
    def __init__(self, datos):
        self.datos = datos

    def calcular_subtotal(self):
        subtotal = 0
        for item in self.datos["items"]:
            subtotal = subtotal + item["precio"] * item["cantidad"]
        return subtotal

    def calcular_descuento(self):
        if self.datos["cliente"]["tipo"] == "vip":
            return self.calcular_subtotal() * 0.10
        return 0

    def calcular_envio(self):
        if self.datos["retiro_en_local"]:
            return 0
        if self.calcular_subtotal() >= 10000:
            return 0
        return 1200

    def calcular_total(self):
        subtotal = self.calcular_subtotal()
        descuento = self.calcular_descuento()
        envio = self.calcular_envio()
        return round(subtotal - descuento + envio, 2)

La función externa puede mantenerse como adaptadora:

def calcular_total(pedido):
    return Pedido(pedido).calcular_total()

15.9 Probar la clase directamente

Además de conservar las pruebas anteriores, podemos probar la clase:

from pedidos_clase import Pedido, calcular_total


def test_pedido_calcula_subtotal():
    pedido = Pedido(
        {
            "cliente": {"nombre": "Ana", "tipo": "vip"},
            "retiro_en_local": False,
            "items": [
                {"precio": 3000, "cantidad": 2},
            ],
        }
    )

    assert pedido.calcular_subtotal() == 6000

Esto permite migrar código nuevo hacia la clase sin eliminar todavía la interfaz anterior.

15.10 Reemplazar diccionario interno por atributos

Guardar el diccionario completo dentro de la clase mantiene cierta dependencia de claves. Podemos avanzar un paso más:

class Pedido:
    def __init__(self, cliente, items, retiro_en_local=False):
        self.cliente = cliente
        self.items = items
        self.retiro_en_local = retiro_en_local

La función adaptadora convierte el diccionario viejo a la nueva forma:

def crear_pedido_desde_dict(datos):
    return Pedido(
        cliente=datos["cliente"],
        items=datos["items"],
        retiro_en_local=datos["retiro_en_local"],
    )

15.11 Código con clase más explícita

La clase queda más clara:

class Pedido:
    def __init__(self, cliente, items, retiro_en_local=False):
        self.cliente = cliente
        self.items = items
        self.retiro_en_local = retiro_en_local

    def calcular_subtotal(self):
        subtotal = 0
        for item in self.items:
            subtotal = subtotal + item["precio"] * item["cantidad"]
        return subtotal

    def calcular_descuento(self):
        if self.cliente["tipo"] == "vip":
            return self.calcular_subtotal() * 0.10
        return 0

    def calcular_envio(self):
        if self.retiro_en_local:
            return 0
        if self.calcular_subtotal() >= 10000:
            return 0
        return 1200

    def calcular_total(self):
        subtotal = self.calcular_subtotal()
        descuento = self.calcular_descuento()
        envio = self.calcular_envio()
        return round(subtotal - descuento + envio, 2)


def crear_pedido_desde_dict(datos):
    return Pedido(
        cliente=datos["cliente"],
        items=datos["items"],
        retiro_en_local=datos["retiro_en_local"],
    )


def calcular_total(pedido):
    return crear_pedido_desde_dict(pedido).calcular_total()

El código nuevo puede construir Pedido directamente; el código viejo puede seguir usando diccionarios por ahora.

15.12 Cuándo usar dataclass

Si la clase principalmente guarda datos y tiene algunos métodos, dataclass puede reducir código repetitivo:

from dataclasses import dataclass


@dataclass
class Pedido:
    cliente: dict
    items: list
    retiro_en_local: bool = False

Luego podemos agregar los métodos de cálculo dentro de la misma clase. En temas posteriores profundizaremos en dataclass y type hints.

15.13 Evitar clases sin responsabilidad real

Una clase que solo envuelve una función sin aportar cohesión no mejora el diseño:

class Calculador:
    def calcular(self, pedido):
        return calcular_total(pedido)

Esta clase no tiene estado propio ni reglas agrupadas. Extraer una clase tiene sentido cuando datos y comportamiento quedan mejor juntos.

15.14 Migración gradual

En proyectos reales, rara vez podemos cambiar todas las llamadas de golpe. Una estrategia segura es:

  • Crear la clase nueva.
  • Mover un método pequeño.
  • Mantener funciones adaptadoras para compatibilidad.
  • Actualizar llamadas nuevas para usar la clase.
  • Eliminar adaptadores cuando ya no se usen.

15.15 Ejercicio propuesto

Crea el archivo src/cursos_clase.py:

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


def generar_resumen(inscripcion):
    total = calcular_precio_final(inscripcion)
    return {
        "alumno": inscripcion["alumno"]["nombre"],
        "curso": inscripcion["curso"]["nombre"],
        "total": total,
    }

Realiza estas tareas:

  • Escribe pruebas para calcular_precio_final y generar_resumen.
  • Crea una clase Inscripcion.
  • Mueve el cálculo del precio a un método.
  • Mueve la generación del resumen a otro método.
  • Mantén funciones adaptadoras para las llamadas existentes.
  • Ejecuta python -m pytest después de cada paso.

15.16 Una posible solución

Una solución posible es:

class Inscripcion:
    def __init__(self, alumno, curso, modalidad):
        self.alumno = alumno
        self.curso = curso
        self.modalidad = modalidad

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

    def generar_resumen(self):
        return {
            "alumno": self.alumno["nombre"],
            "curso": self.curso["nombre"],
            "total": self.calcular_precio_final(),
        }


def crear_inscripcion_desde_dict(datos):
    return Inscripcion(
        alumno=datos["alumno"],
        curso=datos["curso"],
        modalidad=datos["modalidad"],
    )


def calcular_precio_final(inscripcion):
    return crear_inscripcion_desde_dict(inscripcion).calcular_precio_final()


def generar_resumen(inscripcion):
    return crear_inscripcion_desde_dict(inscripcion).generar_resumen()

La clase concentra los datos y reglas de una inscripción, mientras las funciones antiguas siguen disponibles como puente temporal.

15.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Cuándo conviene extraer una clase.
  • Por qué varias funciones sobre el mismo diccionario son una señal importante.
  • Cómo mover un método sin romper funciones existentes.
  • Cómo usar una función adaptadora durante la migración.
  • Cuándo una clase no aporta valor real.
  • Qué ventajas tiene probar la clase directamente.

15.18 Conclusión

En este tema extrajimos una clase desde datos y comportamiento mezclados. Pasamos de funciones que conocían las claves internas de un diccionario a un objeto que concentra reglas relacionadas y ofrece una interfaz más clara.

En el próximo tema refactorizaremos clases grandes hacia responsabilidades pequeñas.