19. Uso práctico de dataclasses para representar datos con claridad

19.1 Objetivo del tema

En muchos proyectos Python empezamos usando diccionarios para representar datos. Eso es práctico al principio, pero puede volverse confuso cuando las mismas claves aparecen en muchas funciones o cuando no queda claro qué estructura se espera.

En este tema veremos cómo usar dataclasses para representar datos con más claridad, reducir errores de claves, mejorar firmas de funciones y hacer que el código comunique mejor el dominio.

Objetivo práctico: reemplazar diccionarios ambiguos por dataclasses simples y usarlas en funciones Python sin complicar el diseño.

19.2 Problema: diccionarios ambiguos

Un diccionario es flexible, pero esa flexibilidad puede ocultar errores.

producto = {
    "precio": 1000,
    "cantidad": 2,
}

Si otra parte del código espera la clave "cant" o "qty", Python no lo detectará hasta que se ejecute esa línea.

def calcular_importe(producto):
    return producto["precio"] * producto["cant"]

El error está en una clave mal nombrada. Una estructura más explícita ayuda a evitarlo.

19.3 Crear una dataclass

Una dataclass permite declarar datos con nombres y tipos esperados.

from dataclasses import dataclass


@dataclass
class Producto:
    precio: float
    cantidad: int

Ahora podemos crear productos así:

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

Y acceder con atributos:

importe = producto.precio * producto.cantidad

19.4 Ventajas para la legibilidad

Una dataclass hace visible la estructura esperada. Al leer la clase, sabemos qué campos componen un producto.

  • Reduce errores por claves mal escritas.
  • Mejora autocompletado en editores.
  • Hace más claras las firmas de funciones.
  • Permite agrupar datos relacionados.
  • Genera automáticamente métodos útiles como __init__ y __repr__.

19.5 Reescribir una función con dataclass

Antes:

def calcular_importe(producto):
    return producto["precio"] * producto["cantidad"]

Después:

def calcular_importe(producto: Producto):
    return producto.precio * producto.cantidad

La función ahora comunica qué tipo de dato espera.

19.6 Lista de dataclasses

También podemos trabajar con listas de productos:

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

El tipo list[Producto] indica que se espera una lista de objetos Producto.

19.7 Valores por defecto

Una dataclass puede tener valores por defecto.

from dataclasses import dataclass


@dataclass
class Cliente:
    email: str
    tipo: str = "nuevo"
    activo: bool = True

Podemos crear un cliente indicando solo el email:

cliente = Cliente(email="ana@example.com")

El tipo será "nuevo" y activo será True.

19.8 Cuidado con valores mutables

No uses listas o diccionarios mutables como valores por defecto directos. Usa field(default_factory=...).

from dataclasses import dataclass, field


@dataclass
class Carrito:
    productos: list[Producto] = field(default_factory=list)

Así cada carrito obtiene su propia lista de productos.

19.9 Validación con __post_init__

Podemos validar datos después de crear el objeto usando __post_init__.

@dataclass
class Producto:
    precio: float
    cantidad: int

    def __post_init__(self):
        if self.precio < 0:
            raise ValueError("El precio no puede ser negativo")
        if self.cantidad <= 0:
            raise ValueError("La cantidad debe ser positiva")

La validación queda cerca de la estructura de datos.

19.10 Dataclasses inmutables

Si un dato no debería cambiar después de crearse, podemos usar frozen=True.

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

Esto ayuda a evitar modificaciones accidentales del estado.

producto = Producto(precio=1000, cantidad=2)
# producto.precio = 2000  # No permitido si frozen=True.

19.11 Representar una venta

Podemos modelar una venta con productos, cliente y país.

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

La firma de la función principal puede simplificarse:

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

19.12 Aplicación sobre ventas_demo

En ventas_demo, puedes crear un archivo src/modelos.py:

from dataclasses import dataclass, field


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

    def __post_init__(self):
        if self.precio < 0:
            raise ValueError("El precio no puede ser negativo")
        if self.cantidad <= 0:
            raise ValueError("La cantidad debe ser positiva")


@dataclass(frozen=True)
class Cliente:
    tipo: str = "nuevo"


@dataclass(frozen=True)
class Venta:
    productos: list[Producto] = field(default_factory=list)
    cliente: Cliente = field(default_factory=Cliente)
    pais: str = "AR"

19.13 Actualizar funciones para usar modelos

Una función de subtotal puede quedar así:

from modelos import Producto


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

La función ya no depende de claves de diccionario. Depende de un modelo explícito.

19.14 Pruebas para dataclasses

Podemos probar la validación del modelo:

import pytest

from modelos import Producto


def test_producto_rechaza_precio_negativo():
    with pytest.raises(ValueError, match="precio"):
        Producto(precio=-1, cantidad=2)


def test_producto_rechaza_cantidad_no_positiva():
    with pytest.raises(ValueError, match="cantidad"):
        Producto(precio=1000, cantidad=0)

También podemos probar el cálculo:

def test_calcular_subtotal_con_productos():
    productos = [
        Producto(precio=1000, cantidad=2),
        Producto(precio=500, cantidad=3),
    ]

    assert calcular_subtotal(productos) == 3500

19.15 Convertir desde diccionarios

Si recibes datos como diccionarios, puedes convertirlos en dataclasses en una capa de entrada.

def crear_producto_desde_dict(datos):
    return Producto(
        precio=float(datos["precio"]),
        cantidad=int(datos["cantidad"]),
    )

Así el resto del sistema trabaja con objetos claros, no con diccionarios sueltos.

19.16 Cuándo no usar dataclass

No uses dataclasses por obligación. Tal vez no aportan mucho cuando:

  • La estructura de datos es muy pequeña y local.
  • Los datos vienen y se van directamente como JSON sin lógica interna.
  • La clase terminaría siendo un contenedor innecesario.
  • El equipo todavía no necesita ese nivel de estructura.

La pregunta clave es si la dataclass mejora la claridad del código.

19.17 Ejercicio guiado

Convierte este código basado en diccionarios a dataclasses:

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

Una posible solución:

from dataclasses import dataclass


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


@dataclass(frozen=True)
class Pedido:
    items: list[ItemPedido]


def calcular_total(pedido: Pedido):
    return sum(
        item.precio * item.cantidad
        for item in pedido.items
    )

19.18 Ejercicio propuesto

En ventas_demo, realiza estas tareas:

  • Crea una dataclass Producto.
  • Reemplaza al menos una función que usa diccionarios por una que usa Producto.
  • Agrega validación con __post_init__.
  • Agrega pruebas para datos válidos e inválidos.
  • Ejecuta herramientas y pruebas.
python -m ruff check src tests
python -m black src tests
python -m pytest

19.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Reconocer cuándo un diccionario se volvió ambiguo.
  • Crear una dataclass con campos y tipos claros.
  • Usar valores por defecto correctamente.
  • Evitar valores mutables por defecto con default_factory.
  • Validar datos con __post_init__.
  • Usar frozen=True cuando conviene evitar mutaciones.
  • Convertir datos externos a modelos internos claros.

19.20 Conclusión

En este tema vimos que dataclasses ayudan a representar datos con claridad, especialmente cuando los diccionarios empiezan a generar errores de claves, firmas confusas o estructuras implícitas.

En el próximo tema estudiaremos tipado gradual con type hints y revisión básica con mypy.