Módulo 5: Patrones de Error Comunes

Naming y Abstracciones Incorrectas

Naming y Abstracciones Incorrectas

Descripción de la cápsula

De todos los errores que AI genera, naming incorrecto y abstracciones mal aplicadas son los más dañinos a largo plazo. Un SQL injection es grave, pero es un bug puntual que corriges una vez. Una función llamada get_user que en realidad también modifica estado, valida permisos y envía un email — eso es una bomba de tiempo que causa bugs acumulativos durante meses.

AI es particularmente propensa a este tipo de error porque optimiza por patrones estadísticos. Ha visto millones de funciones llamadas process_data, handle_request, y get_user. Replica esos nombres genéricos sin considerar si son descriptivos para tu caso. Aplica Factory pattern porque lo vio en mucho código Java, aunque tu aplicación Python solo necesita un if/else.

En esta cápsula vas a entrenar tu ojo para detectar estos patrones. Cada ejemplo sigue la estructura: (a) el código que AI genera, (b) por qué se ve bien a primera vista, (c) el problema real, (d) la corrección correcta.


Patrón 1: Funciones con Nombres Engañosos

El problema

AI genera funciones cuyos nombres prometen una cosa pero hacen varias. El nombre describe solo una fracción de lo que la función realmente hace.

Código que AI genera

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime
import smtplib
from email.mime.text import MIMEText

app = FastAPI()

class User(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool = True
    last_login: datetime | None = None

users_db: dict[int, dict] = {}

async def get_user(user_id: int) -> User:
    """Obtiene un usuario por ID."""
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")

    user_data = users_db[user_id]

    user_data["last_login"] = datetime.now()
    users_db[user_id] = user_data

    if not user_data["is_active"]:
        send_reactivation_email(user_data["email"])
        user_data["is_active"] = True
        users_db[user_id] = user_data

    return User(**user_data)


def send_reactivation_email(email: str) -> None:
    msg = MIMEText("Tu cuenta ha sido reactivada.")
    msg["Subject"] = "Cuenta reactivada"
    msg["From"] = "noreply@app.com"
    msg["To"] = email
    with smtplib.SMTP("localhost") as server:
        server.send_message(msg)

Por qué se ve bien a primera vista

  • La función tiene un docstring claro: "Obtiene un usuario por ID"
  • El nombre get_user es intuitivo y sigue convenciones
  • El código compila, los types están correctos
  • La estructura del código es limpia

El problema real

La función get_user hace tres cosas:

  1. Obtiene el usuario (lo que el nombre promete)
  2. Modifica last_login (efecto secundario oculto)
  3. Reactiva usuarios inactivos y envía email (efecto secundario con side effects externos)

¿Por qué esto es una bomba de tiempo?

Escenarios que van a causar bugs:

1. Un developer usa get_user() en un endpoint de solo lectura
   → Sin saberlo, modifica last_login en cada consulta
   → Los analytics de "último login real" quedan corruptos

2. Un test unitario llama get_user() para verificar datos
   → El test modifica estado como efecto secundario
   → Los tests siguientes fallan de forma intermitente

3. Un dashboard admin consulta usuarios frecuentemente
   → Cada consulta envía email de reactivación a inactivos
   → Los usuarios reciben 50 emails diarios

4. Otro developer lee el nombre y asume que es idempotente
   → Cachea el resultado de get_user()
   → La reactivación nunca se ejecuta para usuarios reales

La corrección

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class User(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool = True
    last_login: datetime | None = None

users_db: dict[int, dict] = {}


async def get_user_by_id(user_id: int) -> User:
    """Obtiene un usuario por ID. Operación de solo lectura."""
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
    return User(**users_db[user_id])


async def record_user_login(user_id: int) -> None:
    """Registra el timestamp del login actual."""
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
    users_db[user_id]["last_login"] = datetime.now()


async def send_reactivation_notification(email: str) -> None:
    """Envía notificación de reactivación al usuario."""
    msg = MIMEText("Tu cuenta ha sido reactivada.")
    msg["Subject"] = "Cuenta reactivada"
    msg["From"] = "noreply@app.com"
    msg["To"] = email
    with smtplib.SMTP("localhost") as server:
        server.send_message(msg)


async def reactivate_user(user_id: int) -> User:
    """Reactiva un usuario inactivo y envía notificación."""
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")

    user_data = users_db[user_id]
    if not user_data["is_active"]:
        user_data["is_active"] = True
        users_db[user_id] = user_data
        await send_reactivation_notification(user_data["email"])

    return User(**user_data)

Por qué la corrección es mejor:

  • Cada función hace exactamente lo que su nombre dice
  • get_user_by_id es de solo lectura — segura para cachear, testear, y usar en cualquier contexto
  • record_user_login es explícita sobre la mutación
  • reactivate_user es explícita sobre el efecto secundario (email)
  • El developer que lee los nombres sabe exactamente qué esperar

Señal de alerta

Cuando veas una función en AI code con un nombre tipo get_X, fetch_X, o load_X, verifica que solo haga lectura. Si modifica estado, el nombre engaña.


Patrón 2: Nombres Genéricos que Ocultan Intención

El problema

AI genera funciones con nombres tan genéricos que no transmiten ningún significado específico. process_data, handle_request, do_operation — estos nombres podrían aplicarse a cualquier función.

Código que AI genera

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class OrderItem(BaseModel):
    product_id: int
    quantity: int
    unit_price: float

class Order(BaseModel):
    id: int
    items: list[OrderItem]
    created_at: datetime
    status: str = "pending"

orders_db: dict[int, dict] = {}


def process_data(data: dict) -> dict:
    """Procesa los datos recibidos."""
    total = 0
    processed_items = []

    for item in data.get("items", []):
        subtotal = item["quantity"] * item["unit_price"]
        if item["quantity"] > 10:
            subtotal *= 0.9
        processed_items.append({
            **item,
            "subtotal": round(subtotal, 2)
        })
        total += subtotal

    if total > 500:
        total *= 0.95

    data["items"] = processed_items
    data["total"] = round(total, 2)
    data["processed"] = True

    return data


def handle_request(request_data: dict) -> dict:
    """Maneja el request."""
    validated = validate_input(request_data)
    result = process_data(validated)
    return format_output(result)


def validate_input(data: dict) -> dict:
    """Valida el input."""
    if "items" not in data:
        raise ValueError("Items required")
    return data


def format_output(data: dict) -> dict:
    """Formatea el output."""
    return {
        "order": data,
        "status": "success",
        "timestamp": datetime.now().isoformat()
    }

Por qué se ve bien a primera vista

  • Las funciones son cortas y siguen el patrón validate → process → format
  • Hay separación de concerns aparente
  • El código tiene docstrings
  • La estructura es limpia

El problema real

Lee solo los nombres de las funciones:

process_data()     → ¿Qué datos? ¿Qué tipo de procesamiento?
handle_request()   → ¿Qué request? ¿De qué endpoint?
validate_input()   → ¿Qué valida específicamente?
format_output()    → ¿Qué formato? ¿Para quién?

Sin leer la implementación, no sabes nada. Compara con nombres descriptivos:

calculate_order_totals_with_discounts()   → Sabes exactamente qué hace
process_new_order_submission()            → Sabes el contexto
validate_order_has_items()                → Sabes qué valida
format_order_api_response()              → Sabes el formato y destino

Además, process_data usa dict como tipo en vez de modelos tipados — otro patrón frecuente de AI donde se pierde type safety.

La corrección

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class OrderItem(BaseModel):
    product_id: int
    quantity: int
    unit_price: float
    subtotal: float = 0.0

class OrderRequest(BaseModel):
    items: list[OrderItem]

class OrderSummary(BaseModel):
    items: list[OrderItem]
    subtotal_before_discount: float
    discount_applied: float
    total: float

BULK_DISCOUNT_THRESHOLD = 10
BULK_DISCOUNT_RATE = 0.10
ORDER_DISCOUNT_THRESHOLD = 500.0
ORDER_DISCOUNT_RATE = 0.05


def calculate_order_totals_with_discounts(order: OrderRequest) -> OrderSummary:
    """Calcula subtotales por item (descuento por volumen >10 unidades)
    y total del pedido (descuento 5% si supera $500)."""
    items_with_subtotals = []
    subtotal_before_discount = 0.0

    for item in order.items:
        item_subtotal = item.quantity * item.unit_price
        if item.quantity > BULK_DISCOUNT_THRESHOLD:
            item_subtotal *= (1 - BULK_DISCOUNT_RATE)
        items_with_subtotals.append(item.model_copy(update={"subtotal": round(item_subtotal, 2)}))
        subtotal_before_discount += item_subtotal

    discount_applied = 0.0
    total = subtotal_before_discount
    if total > ORDER_DISCOUNT_THRESHOLD:
        discount_applied = total * ORDER_DISCOUNT_RATE
        total -= discount_applied

    return OrderSummary(
        items=items_with_subtotals,
        subtotal_before_discount=round(subtotal_before_discount, 2),
        discount_applied=round(discount_applied, 2),
        total=round(total, 2),
    )

Por qué la corrección es mejor:

  • El nombre de la función dice exactamente qué calcula
  • Los modelos Pydantic reemplazan dicts genéricos
  • Las constantes de negocio (umbrales, tasas) son explícitas, no magic numbers
  • Un developer nuevo entiende el propósito leyendo solo el nombre y la firma

Señal de alerta

Nombres que empiezan con process_, handle_, manage_, do_, u operation_ son casi siempre demasiado genéricos. Si no puedes saber qué hace la función leyendo solo su nombre, el nombre es incorrecto.


Patrón 3: Abstracciones Prematuras — Factory Innecesario

El problema

AI aplica patrones de diseño por frecuencia estadística, no por necesidad. Ha visto Factory pattern en miles de repositorios y lo aplica incluso cuando un simple if/else resolvería el problema en 5 líneas.

Código que AI genera

from abc import ABC, abstractmethod
from pydantic import BaseModel


class NotificationBase(BaseModel):
    recipient: str
    message: str


class Notification(ABC):
    @abstractmethod
    def send(self, notification: NotificationBase) -> bool:
        pass

    @abstractmethod
    def validate(self, notification: NotificationBase) -> bool:
        pass


class EmailNotification(Notification):
    def send(self, notification: NotificationBase) -> bool:
        print(f"Sending email to {notification.recipient}: {notification.message}")
        return True

    def validate(self, notification: NotificationBase) -> bool:
        return "@" in notification.recipient


class SMSNotification(Notification):
    def send(self, notification: NotificationBase) -> bool:
        print(f"Sending SMS to {notification.recipient}: {notification.message}")
        return True

    def validate(self, notification: NotificationBase) -> bool:
        return notification.recipient.startswith("+")


class PushNotification(Notification):
    def send(self, notification: NotificationBase) -> bool:
        print(f"Sending push to {notification.recipient}: {notification.message}")
        return True

    def validate(self, notification: NotificationBase) -> bool:
        return len(notification.recipient) > 0


class NotificationFactory:
    _registry: dict[str, type[Notification]] = {
        "email": EmailNotification,
        "sms": SMSNotification,
        "push": PushNotification,
    }

    @classmethod
    def create(cls, notification_type: str) -> Notification:
        if notification_type not in cls._registry:
            raise ValueError(f"Unknown notification type: {notification_type}")
        return cls._registry[notification_type]()

    @classmethod
    def register(cls, notification_type: str, notification_class: type[Notification]) -> None:
        cls._registry[notification_type] = notification_class


def send_notification(notification_type: str, recipient: str, message: str) -> bool:
    factory = NotificationFactory()
    notifier = factory.create(notification_type)
    notification = NotificationBase(recipient=recipient, message=message)

    if not notifier.validate(notification):
        raise ValueError(f"Invalid recipient for {notification_type}")

    return notifier.send(notification)

Por qué se ve bien a primera vista

  • Sigue un patrón de diseño reconocido (Factory)
  • Tiene clase abstracta, implementaciones concretas, y factory
  • Es "extensible" — puedes agregar nuevos tipos de notificación
  • El código está bien estructurado con separación de clases

El problema real

Pregúntate: ¿cuántas veces vas a agregar un nuevo tipo de notificación? En la mayoría de aplicaciones, los tipos de notificación se definen una vez y rara vez cambian. Esta abstracción:

  • Agrega 70 líneas de código para hacer lo que 20 líneas resolverían
  • Crea 6 clases/archivos donde 1 función basta
  • Introduce indirección que dificulta debugging
  • La "extensibilidad" nunca se usa — YAGNI (You Ain't Gonna Need It)
  • El registro dinámico register() es una puerta abierta a bugs de runtime
Costo de la abstracción prematura:

Lectura:     Tienes que navegar 6 clases para entender qué hace send_notification
Debugging:   Si email falla, ¿el bug está en Factory, en EmailNotification, o en Notification?
Testing:     Necesitas tests para cada clase + la factory + la integración
Onboarding:  Un developer nuevo tarda 3x más en entender este código
Cambios:     Agregar un campo a NotificationBase requiere tocar todas las subclases

La corrección

from pydantic import BaseModel, field_validator
from enum import Enum


class NotificationType(str, Enum):
    EMAIL = "email"
    SMS = "sms"
    PUSH = "push"


class NotificationRequest(BaseModel):
    notification_type: NotificationType
    recipient: str
    message: str

    @field_validator("recipient")
    @classmethod
    def validate_recipient(cls, v: str, info) -> str:
        notification_type = info.data.get("notification_type")
        if notification_type == NotificationType.EMAIL and "@" not in v:
            raise ValueError("Email recipient must contain @")
        if notification_type == NotificationType.SMS and not v.startswith("+"):
            raise ValueError("SMS recipient must start with +")
        if not v:
            raise ValueError("Recipient cannot be empty")
        return v


def send_notification(request: NotificationRequest) -> bool:
    """Envía una notificación por el canal especificado."""
    if request.notification_type == NotificationType.EMAIL:
        return _send_email(request.recipient, request.message)
    elif request.notification_type == NotificationType.SMS:
        return _send_sms(request.recipient, request.message)
    elif request.notification_type == NotificationType.PUSH:
        return _send_push(request.recipient, request.message)
    raise ValueError(f"Unsupported type: {request.notification_type}")


def _send_email(recipient: str, message: str) -> bool:
    print(f"Sending email to {recipient}: {message}")
    return True


def _send_sms(recipient: str, message: str) -> bool:
    print(f"Sending SMS to {recipient}: {message}")
    return True


def _send_push(recipient: str, message: str) -> bool:
    print(f"Sending push to {recipient}: {message}")
    return True

Por qué la corrección es mejor:

  • ~40 líneas vs ~70 líneas — más fácil de leer y mantener
  • Un solo punto de entrada (send_notification) — fácil de debuggear
  • Pydantic maneja la validación — no necesitas clases abstractas
  • El Enum garantiza type safety — no puedes pasar un tipo inválido
  • Si en el futuro necesitas el patrón Factory, refactorizas entonces (no antes)

Cuándo SÍ usar Factory

Factory tiene sentido cuando:

  • Tienes 10+ implementaciones que cambian frecuentemente
  • Las implementaciones se cargan desde plugins o configuración externa
  • Diferentes equipos agregan implementaciones independientemente
  • La creación del objeto requiere lógica compleja variable

Si ninguna de estas aplica, un if/else es la abstracción correcta.


Patrón 4: Herencia Profunda Innecesaria

El problema

AI genera jerarquías de herencia de 3-4 niveles donde composición o funciones simples bastarían. Esto viene de su entrenamiento con código Java/C# donde la herencia profunda era más común.

Código que AI genera

from datetime import datetime
from pydantic import BaseModel


class BaseEntity(BaseModel):
    id: int | None = None
    created_at: datetime = datetime.now()
    updated_at: datetime = datetime.now()

    def save(self) -> None:
        self.updated_at = datetime.now()


class BaseUserEntity(BaseEntity):
    username: str
    email: str

    def get_display_name(self) -> str:
        return self.username


class BaseActiveUserEntity(BaseUserEntity):
    is_active: bool = True
    last_login: datetime | None = None

    def deactivate(self) -> None:
        self.is_active = False

    def record_login(self) -> None:
        self.last_login = datetime.now()


class AdminUser(BaseActiveUserEntity):
    permissions: list[str] = []
    admin_level: int = 1

    def has_permission(self, permission: str) -> bool:
        return permission in self.permissions

    def grant_permission(self, permission: str) -> None:
        if permission not in self.permissions:
            self.permissions.append(permission)


class SuperAdminUser(AdminUser):
    can_delete_users: bool = True
    can_modify_system: bool = True

    def has_permission(self, permission: str) -> bool:
        return True

Por qué se ve bien a primera vista

  • Cada nivel de la jerarquía agrega funcionalidad
  • Los nombres siguen una progresión lógica
  • SuperAdmin "es un" AdminUser, AdminUser "es un" ActiveUser...
  • El código es DRY — no repite campos

El problema real

Jerarquía de herencia:
SuperAdminUser → AdminUser → BaseActiveUserEntity → BaseUserEntity → BaseEntity → BaseModel

5 niveles de herencia para un modelo de usuario.

Los problemas concretos:

  1. Rigidez: ¿Qué si necesitas un usuario activo que NO es admin pero tiene permissions? No encaja en la jerarquía.

  2. Fragilidad: Un cambio en BaseEntity.save() afecta a todas las clases hijas. Un bug en nivel 2 se propaga a niveles 3, 4 y 5.

  3. Diamond problem: Si en el futuro necesitas ModeratorUser que hereda de AdminUser pero con reglas de BaseActiveUserEntity diferentes, la herencia se rompe.

  4. datetime.now() en default: Cada instancia comparte el mismo timestamp (evaluado al cargar la clase, no al crear la instancia).

  5. Testing: Para testear SuperAdminUser, necesitas entender 5 niveles de herencia.

La corrección

from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum


class UserRole(str, Enum):
    USER = "user"
    ADMIN = "admin"
    SUPER_ADMIN = "super_admin"


class User(BaseModel):
    id: int | None = None
    username: str
    email: str
    role: UserRole = UserRole.USER
    is_active: bool = True
    permissions: list[str] = Field(default_factory=list)
    last_login: datetime | None = None
    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: datetime = Field(default_factory=datetime.now)


def has_permission(user: User, permission: str) -> bool:
    """SuperAdmin tiene todos los permisos. Otros verifican su lista."""
    if user.role == UserRole.SUPER_ADMIN:
        return True
    return permission in user.permissions


def grant_permission(user: User, permission: str) -> User:
    """Retorna una copia del usuario con el permiso agregado."""
    if permission not in user.permissions:
        updated_permissions = [*user.permissions, permission]
        return user.model_copy(update={"permissions": updated_permissions})
    return user


def record_login(user: User) -> User:
    """Retorna una copia del usuario con last_login actualizado."""
    return user.model_copy(update={"last_login": datetime.now()})


def deactivate_user(user: User) -> User:
    """Retorna una copia del usuario desactivado."""
    return user.model_copy(update={"is_active": False})

Por qué la corrección es mejor:

  • Un solo modelo User con un campo role — sin jerarquía
  • Funciones puras que retornan copias — sin mutación oculta
  • Field(default_factory=datetime.now) resuelve el bug del timestamp compartido
  • Agregar un nuevo rol solo requiere extender el Enum
  • Testing es trivial: creas un User con los campos que necesitas

Señal de alerta

Cuando veas herencia de más de 2 niveles en código AI, pregúntate: "¿Podría resolver esto con composición (un campo que define el tipo) en vez de herencia?" La respuesta casi siempre es sí.


Patrón 5: Patrón de Diseño Mal Aplicado — Singleton Innecesario

El problema

AI aplica Singleton pattern a clases que no lo necesitan. Singleton tiene sentido para recursos verdaderamente globales (connection pools, configuración de sistema). AI lo usa para cualquier clase que "debería tener una sola instancia."

Código que AI genera

from threading import Lock


class DatabaseConfig:
    _instance = None
    _lock = Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False
        return cls._instance

    def __init__(self):
        if self._initialized:
            return
        self.host = "localhost"
        self.port = 5432
        self.database = "myapp"
        self.username = "admin"
        self.password = "secret123"
        self._initialized = True

    def get_connection_string(self) -> str:
        return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"


class Logger:
    _instance = None
    _lock = Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._initialized = False
        return cls._instance

    def __init__(self):
        if self._initialized:
            return
        self.logs: list[str] = []
        self._initialized = True

    def log(self, message: str) -> None:
        self.logs.append(message)

    def get_logs(self) -> list[str]:
        return self.logs.copy()


config = DatabaseConfig()
logger = Logger()

Por qué se ve bien a primera vista

  • Singleton es un patrón reconocido
  • El double-checked locking es la implementación "correcta"
  • La intención (una sola instancia) parece razonable
  • El código es thread-safe

El problema real

  1. DatabaseConfig como Singleton: La configuración debería cargarse desde variables de entorno, no hardcodeada. El Singleton hace imposible tener diferentes configuraciones para dev/staging/production sin modificar la clase.

  2. Logger como Singleton: Python ya tiene logging module que maneja esto. Reinventar logging con una lista es perder funcionalidades (niveles, formateadores, handlers).

  3. Testing: Los Singletons hacen tests dependientes entre sí. Si un test modifica el Singleton, afecta todos los tests posteriores. Necesitas teardown manual.

  4. Password hardcoded: "secret123" directamente en el código. Un problema de seguridad grave que el patrón Singleton oculta visualmente.

  5. Acoplamiento global: Todo el código depende de la instancia global. Cambiar la configuración requiere modificar la clase.

La corrección

from pydantic_settings import BaseSettings
from functools import lru_cache
import logging


class DatabaseConfig(BaseSettings):
    host: str = "localhost"
    port: int = 5432
    database: str = "myapp"
    username: str = "admin"
    password: str

    model_config = {"env_prefix": "DB_"}

    @property
    def connection_string(self) -> str:
        return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"


@lru_cache
def get_database_config() -> DatabaseConfig:
    """Singleton funcional: cachea la instancia pero permite override en tests."""
    return DatabaseConfig()


def get_logger(name: str) -> logging.Logger:
    """Usa el sistema de logging estándar de Python."""
    logger = logging.getLogger(name)
    if not logger.handlers:
        handler = logging.StreamHandler()
        handler.setFormatter(
            logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
        )
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)
    return logger

Por qué la corrección es mejor:

  • BaseSettings carga config desde variables de entorno automáticamente
  • password es obligatorio — no tiene default hardcoded
  • @lru_cache actúa como singleton funcional: cachea pero se puede limpiar con get_database_config.cache_clear() en tests
  • Usa logging estándar de Python en vez de reinventar la rueda
  • Testing: get_database_config.cache_clear() limpia el cache entre tests

Señal de alerta

Cuando veas _instance = None y __new__ override en código AI, pregúntate: "¿Este recurso realmente necesita ser global y único?" En Python, @lru_cache en una función factory o un módulo-level variable suelen ser suficientes.


Resumen de Señales de Alerta

Checklist rápido de naming y abstracciones:

☐ Funciones get_/fetch_/load_ que modifican estado
  → Verificar que solo hagan lectura

☐ Nombres genéricos: process_, handle_, manage_, do_
  → Reemplazar con nombres que describan la acción específica

☐ Factory pattern con < 5 implementaciones
  → Considerar if/else o match/case

☐ Herencia de más de 2 niveles
  → Considerar composición con enums/campos

☐ Singleton con __new__ override
  → Considerar @lru_cache o módulo-level variable

☐ Clases abstractas con una sola implementación
  → Eliminar la abstracción, usar la implementación directa

☐ Magic numbers en lógica de negocio
  → Extraer a constantes con nombres descriptivos

Conexión con Proyecto

El proyecto integrador del módulo 8 contiene 3-4 errores de naming y abstracciones. Específicamente busca:

  • ✅ Al menos una función con nombre engañoso que tiene side effects
  • ✅ Al menos una abstracción prematura (pattern donde no se necesita)
  • ✅ Nombres genéricos que dificultan entender qué hace el código
  • ✅ Constantes de negocio como magic numbers

Lo que practicas en esta cápsula es exactamente lo que aplicarás en el proyecto.


Troubleshooting

"¿Cuándo SÍ es correcto usar nombres genéricos?"

En funciones de utilidad que realmente son genéricas. Un map(), filter(), o sort() son genéricos porque operan sobre cualquier dato. Pero una función que calcula descuentos de pedidos no es genérica — tiene un dominio específico.

"¿Cómo sé si una abstracción es prematura?"

La regla de tres: no crees una abstracción hasta que tengas al menos 3 implementaciones concretas. Si solo tienes 1-2, es prematura.

"¿AI siempre genera estos errores?"

No siempre, pero con alta frecuencia. Si tu prompt es específico sobre naming y estructura, AI genera mejor código. Prompts vagos → nombres vagos.

"¿Refactorizo todo el naming del proyecto o solo lo nuevo?"

Empieza con lo nuevo. Refactorizar naming existente es valioso pero introduce riesgo. Aplica la regla del Boy Scout: deja el código mejor de como lo encontraste, un nombre a la vez.

"¿Qué hago si el equipo usa nombres genéricos por convención?"

Respeta la convención del equipo pero sugiere mejoras gradualmente. Un process_order en un codebase donde todo se llama process_X es consistente, aunque no ideal.


Ejercicios

Ejercicio 1: Identificar naming engañoso

Analiza esta función. ¿Qué hace realmente vs lo que el nombre promete?

from datetime import datetime

users_db: dict[int, dict] = {
    1: {"id": 1, "username": "alice", "email": "alice@example.com", "login_count": 5},
    2: {"id": 2, "username": "bob", "email": "bob@example.com", "login_count": 0},
}

def check_user(user_id: int) -> bool:
    """Verifica si el usuario existe."""
    if user_id not in users_db:
        return False

    user = users_db[user_id]
    user["login_count"] += 1
    user["last_checked"] = datetime.now().isoformat()

    if user["login_count"] > 100:
        user["status"] = "veteran"

    return True
Ver solución

El nombre check_user promete verificación (lectura), pero hace 3 cosas:

  1. Verifica existencia (lo que promete)
  2. Incrementa login_count (mutación oculta)
  3. Asigna status "veteran" si login_count > 100 (lógica de negocio oculta)

Corrección:

from datetime import datetime

def user_exists(user_id: int) -> bool:
    """Verifica si el usuario existe. Solo lectura."""
    return user_id in users_db

def increment_login_count(user_id: int) -> int:
    """Incrementa y retorna el nuevo login count."""
    if user_id not in users_db:
        raise KeyError(f"User {user_id} not found")
    users_db[user_id]["login_count"] += 1
    return users_db[user_id]["login_count"]

def update_veteran_status(user_id: int, threshold: int = 100) -> bool:
    """Marca al usuario como veteran si supera el threshold. Retorna True si se actualizó."""
    if user_id not in users_db:
        raise KeyError(f"User {user_id} not found")
    user = users_db[user_id]
    if user.get("login_count", 0) > threshold and user.get("status") != "veteran":
        user["status"] = "veteran"
        return True
    return False

Cada función hace una sola cosa y su nombre lo refleja.

Ejercicio 2: Simplificar abstracción prematura

Este código usa Strategy pattern para calcular impuestos. Simplifica sin perder funcionalidad.

from abc import ABC, abstractmethod


class TaxStrategy(ABC):
    @abstractmethod
    def calculate(self, amount: float) -> float:
        pass


class USATaxStrategy(TaxStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.08


class EUTaxStrategy(TaxStrategy):
    def calculate(self, amount: float) -> float:
        return amount * 0.21


class TaxCalculator:
    def __init__(self, strategy: TaxStrategy):
        self.strategy = strategy

    def get_tax(self, amount: float) -> float:
        return self.strategy.calculate(amount)


calculator = TaxCalculator(USATaxStrategy())
tax = calculator.get_tax(100.0)
Ver solución

El Strategy pattern es excesivo para 2 implementaciones con una línea cada una.

from enum import Enum

class TaxRegion(str, Enum):
    USA = "usa"
    EU = "eu"

TAX_RATES: dict[TaxRegion, float] = {
    TaxRegion.USA: 0.08,
    TaxRegion.EU: 0.21,
}

def calculate_tax(amount: float, region: TaxRegion) -> float:
    """Calcula el impuesto según la región."""
    rate = TAX_RATES.get(region)
    if rate is None:
        raise ValueError(f"No tax rate defined for region: {region}")
    return amount * rate

tax = calculate_tax(100.0, TaxRegion.USA)

De 4 clases y ~25 líneas a 1 función y ~15 líneas. Si en el futuro necesitas lógica compleja por región (progresiva, exenciones), entonces refactorizas.

Ejercicio 3: Refactorizar herencia a composición

Convierte esta jerarquía de 3 niveles en un modelo plano con enum.

from pydantic import BaseModel


class BaseVehicle(BaseModel):
    make: str
    model: str
    year: int

    def describe(self) -> str:
        return f"{self.year} {self.make} {self.model}"


class MotorVehicle(BaseVehicle):
    engine_size: float
    fuel_type: str = "gasoline"

    def fuel_cost_per_km(self) -> float:
        return self.engine_size * 0.05


class ElectricVehicle(BaseVehicle):
    battery_capacity: float
    range_km: int

    def fuel_cost_per_km(self) -> float:
        return self.battery_capacity * 0.001
Ver solución
from pydantic import BaseModel, model_validator
from enum import Enum


class PowertrainType(str, Enum):
    GASOLINE = "gasoline"
    DIESEL = "diesel"
    ELECTRIC = "electric"
    HYBRID = "hybrid"

COST_PER_KM = {
    PowertrainType.GASOLINE: lambda engine_size: engine_size * 0.05,
    PowertrainType.DIESEL: lambda engine_size: engine_size * 0.04,
    PowertrainType.ELECTRIC: lambda battery_cap: battery_cap * 0.001,
    PowertrainType.HYBRID: lambda engine_size: engine_size * 0.03,
}

class Vehicle(BaseModel):
    make: str
    model: str
    year: int
    powertrain: PowertrainType
    engine_size: float | None = None
    battery_capacity: float | None = None
    range_km: int | None = None

    @model_validator(mode="after")
    def validate_powertrain_fields(self):
        if self.powertrain == PowertrainType.ELECTRIC:
            if self.battery_capacity is None:
                raise ValueError("Electric vehicles require battery_capacity")
        else:
            if self.engine_size is None:
                raise ValueError(f"{self.powertrain.value} vehicles require engine_size")
        return self

    def describe(self) -> str:
        return f"{self.year} {self.make} {self.model} ({self.powertrain.value})"

    def fuel_cost_per_km(self) -> float:
        calculator = COST_PER_KM[self.powertrain]
        if self.powertrain == PowertrainType.ELECTRIC:
            return calculator(self.battery_capacity)
        return calculator(self.engine_size)

Un solo modelo con validación condicional. Agregar HYBRID o HYDROGEN solo requiere una entrada en el Enum y en COST_PER_KM.

Ejercicio 4: Renombrar funciones genéricas

Estas funciones tienen nombres genéricos. Renómbralas basándote en lo que realmente hacen.

users_db: dict[int, dict] = {
    1: {"id": 1, "username": "alice", "banned": False},
    2: {"id": 2, "username": "bob", "banned": False},
}

def process(items: list[dict]) -> list[dict]:
    return [item for item in items if item.get("status") == "active"]

def handle(data: dict) -> dict:
    data["total"] = sum(item["price"] * item["qty"] for item in data["items"])
    data["tax"] = data["total"] * 0.16
    data["grand_total"] = data["total"] + data["tax"]
    return data

def transform(records: list[dict]) -> dict[str, list[dict]]:
    result: dict[str, list[dict]] = {}
    for record in records:
        category = record.get("category", "uncategorized")
        result.setdefault(category, []).append(record)
    return result

def execute(user_id: int, action: str) -> bool:
    if action == "ban":
        users_db[user_id]["banned"] = True
    elif action == "unban":
        users_db[user_id]["banned"] = False
    return True
Ver solución
def filter_active_items(items: list[dict]) -> list[dict]:
    """Retorna solo los items con status 'active'."""
    return [item for item in items if item.get("status") == "active"]


def calculate_order_totals_with_tax(order_data: dict, tax_rate: float = 0.16) -> dict:
    """Calcula subtotal, impuesto y gran total de un pedido."""
    order_data["subtotal"] = sum(
        item["price"] * item["qty"] for item in order_data["items"]
    )
    order_data["tax"] = order_data["subtotal"] * tax_rate
    order_data["grand_total"] = order_data["subtotal"] + order_data["tax"]
    return order_data


def group_records_by_category(records: list[dict]) -> dict[str, list[dict]]:
    """Agrupa registros por su campo 'category'."""
    result: dict[str, list[dict]] = {}
    for record in records:
        category = record.get("category", "uncategorized")
        result.setdefault(category, []).append(record)
    return result


def set_user_ban_status(user_id: int, *, banned: bool) -> None:
    """Establece el estado de ban de un usuario."""
    if user_id not in users_db:
        raise KeyError(f"User {user_id} not found")
    users_db[user_id]["banned"] = banned

Cada nombre describe la acción específica. Notas adicionales:

  • execute con action: str se reemplaza por set_user_ban_status con banned: bool — elimina el string dispatch
  • tax_rate se convierte en parámetro explícito, no magic number
  • set_user_ban_status valida que el usuario exista y usa keyword-only arg para claridad

Resumen

  • Naming engañoso es el error más sutil: funciones get_X que modifican estado son bombas de tiempo
  • Nombres genéricos (process_, handle_) no comunican intención — fuerzan a leer la implementación
  • Abstracciones prematuras (Factory, Strategy) agregan complejidad sin beneficio cuando hay pocas implementaciones
  • Herencia profunda (3+ niveles) crea rigidez — composición con enums es más flexible en Python
  • Singleton mal aplicado complica testing y oculta dependencias globales
  • AI genera estos errores porque replica patrones estadísticos sin evaluar si aplican a tu contexto
  • La regla general: el nombre de una función debe ser suficiente para entender qué hace sin leer su implementación

Recursos Adicionales

  1. Clean Code — Meaningful Names - Capítulo 2 de Clean Code sobre naming efectivo
  2. Refactoring Guru — Code Smells - Catálogo de code smells con refactorings sugeridos
  3. YAGNI — Martin Fowler - "You Aren't Gonna Need It" — por qué las abstracciones prematuras cuestan más de lo que ahorran
  4. Composition over Inheritance — Wikipedia - Principio fundamental de diseño orientado a objetos
  5. Python Design Patterns - Patrones de diseño idiomáticos en Python (no Java traducido)
  6. FastAPI Dependency Injection - Cómo FastAPI resuelve el problema de singletons con dependency injection

Siguiente cápsula: Edge Cases No Manejados — el patrón que causa crashes en producción.


Debugging & Code Review with Claude Code — Módulo 5, Cápsula 02 Claude Code Agentic Development Path — Guía #6 de 11