13. Sustituir códigos mágicos por constantes, enumeraciones y objetos de valor

13.1 Objetivo del tema

Los códigos mágicos son valores escritos directamente en el código sin explicar su significado. Pueden ser números, textos, abreviaturas, estados o combinaciones de datos que representan reglas de negocio.

En este tema reemplazaremos esos valores por constantes, enumeraciones y objetos de valor. El objetivo es hacer explícita la intención, reducir errores de escritura y facilitar cambios futuros.

Objetivo práctico: detectar valores mágicos en Python y reemplazarlos por nombres y estructuras que expresen reglas del dominio.

13.2 Qué es un código mágico

Un código mágico es un valor que solo se entiende si conocemos el contexto completo. Por ejemplo:

if pedido["estado"] == "A":
    total = total * 0.9

if total > 10000:
    envio = 0

¿Qué significa "A"? ¿Por qué 10000? ¿Ese número aparece en otros lugares? El problema no es el valor en sí, sino la falta de intención visible.

13.3 Código inicial

Crea el archivo src/pedidos_estado.py:

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

    if pedido["cliente"] == "VIP":
        total = total * 0.9

    if pedido["estado"] == "C":
        total = 0
    elif pedido["estado"] == "P":
        total = total + 500

    if total >= 10000:
        envio = 0
    else:
        envio = 1200

    return round(total + envio, 2)

Hay varios valores difíciles de interpretar: "VIP", "C", "P", 0.9, 500, 10000 y 1200.

13.4 Pruebas antes de cambiar

Crea tests/test_pedidos_estado.py:

from pedidos_estado import calcular_total_pedido


def test_calcula_pedido_vip_pendiente_con_envio():
    pedido = {
        "cliente": "VIP",
        "estado": "P",
        "items": [
            {"precio": 3000, "cantidad": 2},
        ],
    }

    assert calcular_total_pedido(pedido) == 7100.0


def test_calcula_pedido_cancelado():
    pedido = {
        "cliente": "REG",
        "estado": "C",
        "items": [
            {"precio": 20000, "cantidad": 1},
        ],
    }

    assert calcular_total_pedido(pedido) == 1200

Ejecuta:

python -m pytest tests/test_pedidos_estado.py

13.5 Extraer constantes simples

El primer paso es dar nombre a los valores numéricos y textos evidentes:

CLIENTE_VIP = "VIP"
ESTADO_CANCELADO = "C"
ESTADO_PENDIENTE = "P"
DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200

Luego reemplazamos los valores directos por constantes. Este cambio no altera el comportamiento, pero mejora la lectura.

13.6 Código con constantes

La función puede quedar así:

CLIENTE_VIP = "VIP"
ESTADO_CANCELADO = "C"
ESTADO_PENDIENTE = "P"
DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200


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

    if pedido["cliente"] == CLIENTE_VIP:
        total = total * DESCUENTO_VIP

    if pedido["estado"] == ESTADO_CANCELADO:
        total = 0
    elif pedido["estado"] == ESTADO_PENDIENTE:
        total = total + RECARGO_PENDIENTE

    if total >= LIMITE_ENVIO_GRATIS:
        envio = 0
    else:
        envio = COSTO_ENVIO

    return round(total + envio, 2)

Después del cambio ejecuta python -m pytest tests/test_pedidos_estado.py.

13.7 Mejorar nombres de constantes

La constante DESCUENTO_VIP = 0.9 puede confundir: ¿es el descuento o el factor que queda después del descuento? Un nombre más preciso es:

FACTOR_DESCUENTO_VIP = 0.9

También podríamos modelarlo como porcentaje:

PORCENTAJE_DESCUENTO_VIP = 0.10
total = total * (1 - PORCENTAJE_DESCUENTO_VIP)

Ambas opciones son válidas. Lo importante es que el nombre no mienta.

13.8 Usar Enum para estados

Cuando un campo puede tomar un conjunto limitado de valores, una enumeración puede ser más expresiva que strings sueltos.

from enum import Enum


class EstadoPedido(Enum):
    CANCELADO = "C"
    PENDIENTE = "P"
    CONFIRMADO = "F"

La enumeración agrupa los estados posibles y reduce errores de escritura como "cancelado", "Cancelado" o "CAN".

13.9 Adaptar la función a Enum sin romper datos externos

Si el pedido todavía llega como diccionario con strings, podemos convertir el valor al enum dentro de la función:

def obtener_estado_pedido(pedido):
    return EstadoPedido(pedido["estado"])

Luego usamos el enum en las comparaciones:

estado = obtener_estado_pedido(pedido)

if estado == EstadoPedido.CANCELADO:
    total = 0
elif estado == EstadoPedido.PENDIENTE:
    total = total + RECARGO_PENDIENTE

La interfaz externa sigue aceptando "C" y "P", pero el código interno trabaja con nombres claros.

13.10 Código usando Enum

Una versión intermedia puede ser:

from enum import Enum


class EstadoPedido(Enum):
    CANCELADO = "C"
    PENDIENTE = "P"
    CONFIRMADO = "F"


CLIENTE_VIP = "VIP"
FACTOR_DESCUENTO_VIP = 0.9
RECARGO_PENDIENTE = 500
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1200


def obtener_estado_pedido(pedido):
    return EstadoPedido(pedido["estado"])


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

    if pedido["cliente"] == CLIENTE_VIP:
        total = total * FACTOR_DESCUENTO_VIP

    estado = obtener_estado_pedido(pedido)
    if estado == EstadoPedido.CANCELADO:
        total = 0
    elif estado == EstadoPedido.PENDIENTE:
        total = total + RECARGO_PENDIENTE

    if total >= LIMITE_ENVIO_GRATIS:
        envio = 0
    else:
        envio = COSTO_ENVIO

    return round(total + envio, 2)

Ejecuta las pruebas para confirmar que la conversión no cambió el resultado.

13.11 Crear un objeto de valor

Algunos valores tienen reglas propias. Por ejemplo, el dinero no es solo un número: puede requerir redondeo, evitar negativos o aplicar operaciones con criterio.

Podemos crear un objeto de valor simple con dataclass:

from dataclasses import dataclass


@dataclass(frozen=True)
class Dinero:
    monto: float

    def redondeado(self):
        return round(self.monto, 2)

    def sumar(self, otro_monto):
        return Dinero(self.monto + otro_monto)

frozen=True hace que el objeto sea inmutable: una vez creado, no se modifican sus atributos.

13.12 Cuándo usar objeto de valor

No hace falta crear un objeto de valor para cada número. Tiene sentido cuando el valor representa un concepto importante y tiene reglas asociadas.

  • Dinero con moneda y redondeo.
  • Porcentaje con validación de rango.
  • Email con validación de formato.
  • Código de producto con normalización.
  • Rango de fechas con inicio y fin.

13.13 Evitar constantes genéricas

Una constante debe explicar la regla. Nombres como NUMERO_UNO, TEXTO_A o VALOR_MAXIMO suelen ser demasiado genéricos.

# Poco claro
VALOR_MAXIMO = 10000

# Más claro
LIMITE_ENVIO_GRATIS = 10000

El segundo nombre permite entender por qué existe ese número.

13.14 Probar valores inválidos

Al introducir Enum, un estado inválido produce ValueError. Podemos caracterizar ese comportamiento:

import pytest

from pedidos_estado import calcular_total_pedido


def test_falla_con_estado_desconocido():
    pedido = {
        "cliente": "REG",
        "estado": "X",
        "items": [],
    }

    with pytest.raises(ValueError):
        calcular_total_pedido(pedido)

El comportamiento puede cambiar más adelante si decidimos devolver un error de dominio, pero eso ya sería otra modificación.

13.15 Ejercicio propuesto

Crea el archivo src/suscripciones.py:

def calcular_precio_suscripcion(suscripcion):
    precio = 0
    if suscripcion["plan"] == "B":
        precio = 5000
    elif suscripcion["plan"] == "P":
        precio = 9000
    elif suscripcion["plan"] == "E":
        precio = 15000

    if suscripcion["periodo"] == "A":
        precio = precio * 10

    if suscripcion["estado"] == "C":
        precio = 0

    return precio

Realiza estas tareas:

  • Escribe pruebas para plan básico mensual, plan premium anual y suscripción cancelada.
  • Extrae constantes para precios y período anual.
  • Crea Enum para plan, período y estado.
  • Convierte los strings de entrada a enum dentro de la función.
  • Ejecuta python -m pytest después de cada grupo de cambios.

13.16 Una posible solución

Una versión refactorizada puede ser:

from enum import Enum


class Plan(Enum):
    BASICO = "B"
    PREMIUM = "P"
    EMPRESA = "E"


class Periodo(Enum):
    MENSUAL = "M"
    ANUAL = "A"


class EstadoSuscripcion(Enum):
    ACTIVA = "A"
    CANCELADA = "C"


PRECIO_PLAN_BASICO = 5000
PRECIO_PLAN_PREMIUM = 9000
PRECIO_PLAN_EMPRESA = 15000
MESES_FACTURADOS_EN_ANUAL = 10


def obtener_precio_base(plan):
    if plan == Plan.BASICO:
        return PRECIO_PLAN_BASICO
    if plan == Plan.PREMIUM:
        return PRECIO_PLAN_PREMIUM
    if plan == Plan.EMPRESA:
        return PRECIO_PLAN_EMPRESA
    return 0


def calcular_precio_suscripcion(suscripcion):
    plan = Plan(suscripcion["plan"])
    periodo = Periodo(suscripcion["periodo"])
    estado = EstadoSuscripcion(suscripcion["estado"])

    precio = obtener_precio_base(plan)

    if periodo == Periodo.ANUAL:
        precio = precio * MESES_FACTURADOS_EN_ANUAL

    if estado == EstadoSuscripcion.CANCELADA:
        precio = 0

    return precio

Los códigos externos siguen siendo cortos, pero el código interno ahora usa nombres explícitos.

13.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué es un código mágico.
  • Cuándo alcanza con una constante.
  • Cuándo conviene usar Enum.
  • Cómo convertir strings externos a enums internos.
  • Qué problema resuelve un objeto de valor.
  • Por qué el nombre de una constante debe expresar una regla.

13.18 Conclusión

En este tema reemplazamos códigos mágicos por constantes, enumeraciones y objetos de valor. Al dar nombre a números y textos importantes, el código comunica mejor sus reglas y reduce errores por valores mal escritos.

En el próximo tema moveremos responsabilidades entre funciones, clases y módulos para mejorar la organización del código.