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.pyincluyetenant_iden elWHERE. Sin excepción, sin workaround. - Todo
service.pyinvalida Redis en cada write que afecte el menú público. select()de SQLAlchemy 2.0. Prohibidodb.query().- Relaciones con
selectinload()ojoinedload(). Nuncalazy="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_urlcontrasettings.STORAGE_PUBLIC_URLantes 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
SELECTsinWHERE tenant_id = ?en tablas de negocio.except Exception:sinlogger.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_idraw 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.