Saltar a contenido

Clean Code y Complejidad Computacional — Gastro Backend

Cuadrante 04_EXPLANATION — principios y razonamiento. Estos principios aplican a todo archivo Python del backend y se auditan en code review. Aplica también al frontend con las diferencias propias de TypeScript/React.


1. Clean Code — Estándares no negociables

1.1 Nombres: intención sobre brevedad

# ❌ Vago: ¿qué es "d"? ¿qué hace "proc"?
async def proc(d, p):
    res = await d.execute(select(GastroProduct).where(GastroProduct.id == p))
    return res.scalar_one_or_none()

# ✅ Auto-explicativo: nombre del parámetro describe su rol
async def get_product_by_id(db: AsyncSession, product_id: UUID) -> GastroProduct | None:
    result = await db.execute(
        select(GastroProduct).where(GastroProduct.id == product_id)
    )
    return result.scalar_one_or_none()

Reglas de naming:

Capa Convención Ejemplos
Repository Verbos de acceso a datos get_by_id, get_active_by_tenant, update_price, soft_delete
Service Verbos de intención de negocio update_price, toggle_availability, invalidate_menu_cache
Router Verbos HTTP explícitos create_product, update_price, toggle_product
Variables Sin abreviaciones tenant_id (no tid), product_id (no pid), new_price (no p)
Booleans Prefijo is_ o has_ is_active, is_featured, has_image

1.2 Funciones: una responsabilidad, sin efectos ocultos

# ❌ Hace demasiado: valida + persiste + invalida cache + emite WS
async def update_product_price_and_notify(db, ws, redis, product_id, tenant_id, new_price):
    if new_price <= 0: raise ...
    await db.execute(update(...)...)
    await db.execute(insert(...)...)
    await db.commit()
    await redis.delete(...)
    await ws.broadcast(...)

# ✅ Service orquesta responsabilidades separadas
async def update_price(self, tenant_id: UUID, product_id: UUID, new_price: Decimal) -> ProductResponse:
    self._validate_price(new_price)                           # 1. valida
    updated = await self._persist_price_change(               # 2. persiste (TX atómica)
        tenant_id, product_id, new_price
    )
    await self.cache.invalidate_menu(tenant_id)               # 3. invalida cache
    await self.ws.broadcast_price_updated(tenant_id, updated) # 4. notifica
    return ProductResponse.model_validate(updated)            # 5. responde

Límites de tamaño (obligatorios, auditados en code review):

Elemento Máximo Si supera →
Función / método 25 líneas Extraer en funciones privadas con _
Clase Service 150 líneas Dividir en sub-services por responsabilidad
Clase Repository 200 líneas Normal (muchos métodos de acceso a datos)
Archivo .py 300 líneas Señal de que el módulo hace demasiado
Parámetros por función 4 máx Si hay más → crear un dataclass/schema

1.3 Comentarios: solo el WHY, nunca el WHAT

# ❌ El código ya dice QUÉ hace — comentario redundante
# Obtener el producto por ID
product = await repo.get_by_id(product_id, tenant_id)

# ✅ El comentario explica POR QUÉ — algo que el código no puede expresar
# tenant_id en el WHERE es la garantía de aislamiento multi-tenant.
# Sin él, un bug en el router podría exponer datos de otro restaurante.
product = await repo.get_by_id(product_id, tenant_id)

# ✅ Explica una decisión no obvia (constraint del negocio)
# gastro_price_history es append-only: no tiene deleted_at ni se borra nunca.
# Es el audit trail legal de cambios de precio — requerido por algunos países.

No documentar qué hace el código. Documentar por qué existe la restricción, el workaround o la decisión.


1.4 DRY — sin duplicación, sin abstracciones prematuras

# ❌ Mismo WHERE copiado en 3 métodos del repository
.where(GastroProduct.tenant_id == tenant_id, GastroProduct.deleted_at.is_(None))

# ✅ Extraer el filtro base como método privado del repository
def _base_query(self, tenant_id: UUID) -> Select:
    """Filtro común a todas las queries del tenant. Incluye soft-delete."""
    return (
        select(GastroProduct)
        .where(
            GastroProduct.tenant_id == tenant_id,  # aislamiento multi-tenant
            GastroProduct.deleted_at.is_(None),
        )
    )

async def get_active_by_tenant(self, tenant_id: UUID) -> list[GastroProduct]:
    stmt = self._base_query(tenant_id).where(GastroProduct.is_active == True)
    ...

2. Complejidad Computacional — Análisis Big O

Cada operación crítica debe conocer su complejidad antes de implementarse. Si la complejidad es mayor a O(n), documentar por qué es aceptable o cómo se mitiga.

2.1 Escala aceptable (regla universal)

Complejidad Estado Condición
O(1) ✅ Siempre aceptable
O(log n) ✅ Siempre aceptable Índices B-Tree de PostgreSQL
O(n) ⚠️ Aceptable si n está acotado Máx 500 productos, 50 categorías, 100 WS conn
O(n log n) ⚠️ Justificar con comentario Solo en operaciones de ordenamiento puntual
O(n²) 🔴 Prohibido en producción Requiere aprobación explícita + documentación
O(n³)+ 🔴 Prohibido absolutamente Sin excepciones

2.2 Tabla de operaciones del M1 (Carta Digital)

Operación Endpoint Complejidad Mitigación
Obtener carta pública GET /public/menu/{slug} O(1) Redis HIT Cache TTL 60s
Obtener carta pública (miss) GET /public/menu/{slug} O(n) n=productos activos Índice parcial en DB + write-through cache
Listar productos del admin GET /products/ O(n) paginado Page[T] size=50, índice por tenant
Crear producto POST /products/ O(1) INSERT simple
Cambiar precio PATCH /products/{id}/price O(1) UPDATE + INSERT history en TX
Toggle activo PATCH /products/{id}/toggle O(1) UPDATE simple
Reordenar categorías PATCH /categories/reorder O(n) n=categorías Bulk UPDATE con CASE WHEN, máx 50 categorías
WebSocket broadcast Interno O(c) c=conexiones del tenant Set de WS por tenant, máx 100
Invalidar cache Interno O(1) Redis DEL por key

2.3 Los 3 anti-patrones más comunes en este codebase

Anti-patrón 1: N+1 queries (O(n) queries en lugar de O(1))

# ❌ 1 query de categorías + N queries de productos = O(n) queries
for cat in categories:
    products = await db.execute(select(GastroProduct).where(...cat.id))

# ✅ 2 queries totales con selectinload = O(1) queries
stmt = select(GastroCategory).options(selectinload(GastroCategory.products))

Anti-patrón 2: Filtrar en Python lo que debe filtrar la DB

# ❌ O(n) en memoria Python, carga todos los productos primero
all_products = await repo.get_all_by_tenant(tenant_id)
active = [p for p in all_products if p.is_active]

# ✅ O(log n) en PostgreSQL con índice parcial
active = await repo.get_active_by_tenant(tenant_id)  # WHERE en la query
# El índice idx_gastro_products_tenant_menu ya filtra activos (WHERE clause)

Anti-patrón 3: Bucle con writes individuales (O(n) round-trips a DB)

# ❌ N queries para reordenar N productos
for i, product_id in enumerate(ids):
    await db.execute(update(GastroProduct).where(id=product_id).values(sort_order=i))

# ✅ 1 query con CASE WHEN para N productos
await repo.bulk_reorder(ids, tenant_id)  # implementado como bulk UPDATE
# Implementación canónica del bulk reorder:
cases = " ".join(f"WHEN id='{pid}' THEN {i}" for i, pid in enumerate(ids))
await db.execute(text(
    f"UPDATE gastro_products SET sort_order = CASE {cases} END "
    f"WHERE tenant_id = :tenant_id AND id = ANY(:ids)",
    {"tenant_id": tenant_id, "ids": ids}
))

2.4 Documentar complejidad no obvia

Si una operación es O(n) o mayor, añadir comentario junto al método:

async def reorder_categories(self, ids: list[UUID], tenant_id: UUID) -> None:
    # O(n) donde n = len(ids). Acotado: máx 50 categorías por tenant.
    # Bulk UPDATE con CASE WHEN — 1 sola query independiente del n.
    ...

2.5 Complejidad de la carta pública (path más crítico)

Redis HIT:
  Latencia target: p50 < 2ms, p99 < 5ms
  Complejidad: O(1) — GET de string en Redis

Redis MISS (cold start o TTL expirado):
  Latencia target: p50 < 20ms, p99 < 50ms
  Complejidad: O(n) donde n = productos activos del tenant
  Mitigaciones:
    1. Índice idx_gastro_products_tenant_menu (B-Tree parcial, solo activos)
    2. selectinload en 2 queries: categorías + todos sus productos
    3. Serialización a JSON + Redis.set inmediato (write-through)
    4. Límite de 500 productos/tenant → n acotado

2.6 Frontend: Anti-patrón de Modales O(n) vs Modal Global O(1)

El frontend en React puede sufrir de complejidad de memoria/DOM O(n) si no se abstrae el estado efímero correctamente, violando el [SV Standard §5.1].

Anti-patrón 4: Componentes pesados (Modals) instanciados en listas O(n)

// ❌ O(n) DOM nodes. Si hay 200 productos, React inyecta 200 modales invisibles.
// Consume memoria, rompe el INP y laggea el dispositivo en scroll.
const ProductCard = ({ product }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <div onClick={() => setIsOpen(true)}>{product.name}</div>
      <ProductDetailModal isOpen={isOpen} product={product} />
    </>
  )
}
// ✅ O(1) DOM nodes. El estado vive en la URL y solo existe 1 modal global.
// La tarjeta solo actualiza el estado de la URL.
import { useQueryState } from 'nuqs';

const ProductCard = ({ product }) => {
  const [, setProductId] = useQueryState('product');
  return <div onClick={() => setProductId(product.id)}>{product.name}</div>
}

// En el layout o root (GlobalProductModal.tsx)
const GlobalModal = () => {
  const [productId] = useQueryState('product');
  const product = findProduct(productId);
  return <ProductDetailModal isOpen={!!product} product={product} />
}

Beneficios de la aproximación O(1) con URL State: 1. Rendimiento: 1 solo nodo modal en lugar de N. 2. Product-Led Growth: El estado en la URL (?product=123) permite compartir enlaces directos por WhatsApp o redes. 3. Navegación nativa: El botón "Atrás" de Android limpia el parámetro de la URL, cerrando el modal de forma nativa sin hacks de history.pushState.


3. Reglas operativas consolidadas

🟢 Siempre

  • Todo repository.py incluye tenant_id en el WHERE. Sin excepción, sin workaround.
  • Todo service.py invalida Redis en cada write que afecte el menú público.
  • select() de SQLAlchemy 2.0. Prohibido db.query().
  • Relaciones con selectinload() o joinedload(). Nunca lazy="select".
  • Errores de negocio = excepciones tipadas de core/exceptions.py. Nunca strings raw.
  • UPDATE de precio + INSERT de history en la misma async with db.begin().
  • Validar image_url contra settings.STORAGE_PUBLIC_URL antes de persistir.
  • Funciones ≤ 25 líneas. Clases Service ≤ 150 líneas. Archivos ≤ 300 líneas.
  • Documentar complejidad si es O(n) o mayor (comentario junto al método).

🔴 Nunca

  • SELECT sin WHERE tenant_id = ? en tablas de negocio.
  • except Exception: sin logger.exception(...) con stack trace estructurado.
  • Bytes de imagen pasando por el backend en flujos legacy. Solo presigned URL o pipeline Pillow controlado.
  • hashed_password, deleted_at, tenant_id raw en responses externas.
  • Confirmar pago antes de registrar en BD (cuando se implemente M4).
  • lazy="select" en relaciones (N+1 garantizado).
  • Filtrar en Python lo que puede filtrar PostgreSQL con índice.
  • Bucle con queries dentro (for item in list: await db.execute(...) = N+1).
  • Funciones con más de 4 parámetros sin usar dataclass/schema.

Estándar aplicable a todo el código del backend de Gastro — auditado en code review.