M2 — Backend profundo
Outcome del módulo: diseñar un sistema y defender cada trade-off — por qué esa DB, ese índice, ese cache, esa cola, y qué se rompe primero bajo carga o fallo.
El sub-roadmap de System Design tiene 4 fases: Networking → Building Blocks → Patterns → Real-World.
Framework de resolución — cómo atacar un problema de System Design (6 pasos)
Dos cosas distintas: el sub-roadmap 4 fases = qué tecnologías/conceptos aprender. Este framework 6 pasos = cómo atacar un problema concreto (entrevista o diseño real). Fuente: Hemant Pandey.
Tesis central: System Design NO es un examen de arquitectura, es un examen de pensamiento. El error #1 (aun de ingenieros de 7+ años): empiezan a dibujar cajas (Redis, Kafka, Cassandra, K8s) antes de preguntar qué problema resuelven. Pedile a 5 staff engineers que diseñen YouTube y tendrás 5 respuestas correctas distintas — el interviewer evalúa cómo pensás, no la respuesta: asunciones, tradeoffs, simplificación, comunicación.
flowchart TD
S1[1· Clarificar requisitos<br/>preguntar ANTES de dibujar] --> S2[2· Definir las APIs<br/>cómo interactúa el usuario]
S2 --> S3[3· Arquitectura high-level<br/>conectar piezas, big picture]
S3 --> S4[4· Profundizar en lo que importa<br/>gastá tiempo donde está la complejidad]
S4 --> S5[5· Discutir tradeoffs<br/>donde el senior se separa]
S5 --> S6[6· Identificar cuellos<br/>qué se rompe primero y cómo lo arreglo]
Paso 1 — Clarificar requisitos (acá se gana la entrevista). Preguntar antes de dibujar: ¿cuántos usuarios? ¿read-heavy o write-heavy? ¿latencia esperada? ¿availability vs consistency? ¿presupuesto? Cada respuesta cambia la arquitectura. 5 min acá ahorran 20 después. (Es el mismo instinto que M0: necesidad primero.)
Paso 2 — Definir las APIs. Antes de elegir DB/Redis/Kafka, pensá cómo el usuario interactúa (Get Feed, Create Post, Like…). Con las APIs claras, lo demás se ordena.
Paso 3 — Arquitectura high-level. Recién ahora conectás clientes, load balancers, app servers, caches, DBs, colas. Mostrás que entendés el panorama, sin detalle todavía.
Paso 4 — Profundizar donde importa. No expliques todo. WhatsApp → entrega de mensajes. YouTube → storage/streaming de video. Uber → updates de ubicación. Gastá el tiempo donde está la complejidad real.
Paso 5 — Discutir tradeoffs (el separador senior). Toda decisión tiene costo: más cache = menos latencia pero datos stale · replicación = más availability pero desafíos de consistencia · sharding = más escala pero joins difíciles. No buscan la respuesta perfecta, buscan decisiones pensadas.
Paso 6 — Identificar cuellos. ¿Qué se rompe primero: DB, cache, red, cola? ¿Cómo lo arreglás? Pensar hacia adelante muestra madurez.
Anti-patrones (los errores que se repiten):
- ❌ Saltar a la arquitectura demasiado rápido → entendé el problema primero
- ❌ Overengineering → no diseñes para 1.000M si te pidieron 1M
- ❌ Ignorar tradeoffs → no hay tecnología perfecta, solo mejores tradeoffs
- ❌ No comunicar → el interviewer no te lee la mente; verbalizá cada decisión
Nota atómica: Front: ¿por qué fallan System Design ingenieros técnicamente buenos? Back: porque empiezan a diseñar antes de pensar — dibujan arquitectura sin clarificar requisitos. Es examen de pensamiento (asunciones, tradeoffs, comunicación), no de memorizar arquitecturas.
Feynman: “Es como un arquitecto al que le pedís ‘una casa’. El malo empieza a dibujar mansiones. El bueno pregunta: ¿para cuántas personas? ¿presupuesto? ¿clima? La casa sale de las respuestas, no del catálogo.”
Aplicar: agarrá 20 problemas clásicos (Instagram, Dropbox, WhatsApp, Uber, TinyURL, Google Maps…) y resolvé cada uno con estos 6 pasos. A los ~5 vas a ver que comparten building blocks — dejás de memorizar y empezás a reconocer patrones.
Recurso: Grokking the Modern System Design Interview (Educative) — enseña el proceso (requisitos → arquitectura → tradeoffs), no arquitecturas sueltas. [mención del autor original; verificar si vale la pena pagarlo vs el material gratis].
Concepto 1 — Índices, EXPLAIN y el problema N+1
Idea núcleo: un índice es una estructura (B-tree) que evita escanear toda la tabla. Sin él, cada query es un full scan; con él mal usado, seguís escaneando.
Entender (capa 1)
Texto: una tabla sin índice se recorre fila por fila (O(n)). Un índice B-tree la busca en O(log n). Pero el índice solo sirve si la query filtra/ordena por su columna. EXPLAIN te dice si la DB usa el índice (Index Scan) o lo ignora (Seq Scan).
Visual:
flowchart TD
Q[SELECT ... WHERE email = ?] --> D{¿hay índice en email?}
D -->|Sí| I[Index Scan · O log n · lee pocas páginas]
D -->|No| S[Seq Scan · O n · lee TODA la tabla]
Video: Database Indexing Explained (with PostgreSQL) — Hussein Nasser
Ejemplo (diagnóstico):
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'x@y.com';
-- "Seq Scan on users" → falta índice
CREATE INDEX idx_users_email ON users(email);
-- ahora: "Index Scan using idx_users_email"
El N+1 (el bug de performance más común con ORM):
# ❌ N+1: 1 query para los posts + 1 query por cada autor
for post in Post.objects.all(): # 1
print(post.author.name) # +N (una por post)
# ✅ 1-2 queries con eager loading (join/prefetch)
for post in Post.objects.select_related("author"):
print(post.author.name)
Fijar (capa 2)
Nota atómica:
- Front: ¿qué es el problema N+1 y cómo se detecta?
- Back: 1 query inicial + N queries extra (una por fila) al acceder a una relación lazy. Se detecta viendo el conteo de queries (o logs SQL). Se arregla con eager loading (
select_related/join/prefetch).
Feynman: “El índice es el índice alfabético de un libro: en vez de leer las 800 páginas para encontrar ‘Python’, vas directo. El N+1 es ir a la biblioteca 100 veces por 100 libros en vez de pedirlos todos en un solo viaje.”
Aplicar (capa 3)
Tomá una query lenta, corré EXPLAIN ANALYZE, agregá el índice, verificá que cambió a Index Scan y bajó el tiempo. Reproducí un N+1 y arreglalo con eager loading.
Límites
- Índices no son gratis: frenan writes (hay que actualizarlos) y ocupan espacio. No indexes todo.
- Un índice en columna de baja cardinalidad (ej:
bool activo) casi no sirve.
Concepto 2 — Transacciones y niveles de aislamiento (ACID)
Idea núcleo: una transacción agrupa operaciones en una unidad atómica (todo o nada). El nivel de aislamiento define qué anomalías podés ver cuando hay concurrencia.
Entender (capa 1)
Texto: ACID = Atomicity (todo o nada), Consistency (reglas válidas), Isolation (transacciones no se pisan), Durability (una vez commit, persiste). El aislamiento tiene niveles; más aislamiento = menos anomalías pero menos concurrencia.
Anomalías por nivel:
flowchart LR
RU[Read Uncommitted] -->|permite| DR[Dirty Read]
RC[Read Committed] -->|permite| NR[Non-repeatable Read]
RR[Repeatable Read] -->|permite| PH[Phantom Read]
SER[Serializable] -->|no permite| NONE[ninguna]
Nota: el diagrama es el modelo ANSI. En Postgres, Repeatable Read (snapshot isolation) ya evita phantom reads — es más estricto que el estándar en ese punto.
Video: Serializable vs Repeatable Read Isolation Level — Hussein Nasser
Ejemplo:
# atómico: o se descuenta Y se acredita, o nada
with db.transaction():
cuenta_a.balance -= 100
cuenta_b.balance += 100
# si algo falla acá, rollback total — nunca queda plata a medias
Fijar (capa 2)
Nota atómica:
- Front: ¿qué es un dirty read y qué nivel lo evita?
- Back: leer datos de otra transacción que aún NO hizo commit (y podría hacer rollback). Lo evita
Read Committeden adelante.
Feynman: “Una transacción es una apuesta con retiro: o pasan todas las jugadas o se cancela la mesa entera. El nivel de aislamiento es cuánto dejás que los otros jugadores vean tus cartas antes de bajarlas.”
Aplicar (capa 3)
Simulá dos transacciones concurrentes sobre la misma fila. Observá el comportamiento en Read Committed vs Serializable. Provocá un deadlock y vé cómo la DB aborta una.
Límites
Serializablees el más seguro pero el que más aborta transacciones bajo contención → hay que reintentar.- La mayoría de apps corren en
Read Committed(default Postgres) y suben solo donde hace falta.
Concepto 3 — Paginación: cursor vs offset
Idea núcleo: OFFSET se vuelve lento en páginas profundas; el cursor (keyset) es estable y O(log n).
Entender:
-- ❌ offset: la DB igual lee y descarta las 100000 filas anteriores
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;
-- ✅ keyset/cursor: salta directo con el índice
SELECT * FROM posts WHERE id > :last_id ORDER BY id LIMIT 20;
Nota atómica: Front: ¿por qué offset alto es lento? Back: la DB lee y descarta todas las filas del offset antes de devolver; O(offset). El cursor usa el índice para saltar: O(log n) y estable ante inserciones. Feynman: “Offset es contar desde la página 1 cada vez que abrís el libro. Cursor es poner un señalador donde quedaste.” Aplicar: implementá paginación por cursor en una API y compará el tiempo contra offset en la página 10.000. Límites: cursor no permite “saltar a página N” arbitraria; sirve para scroll/feed, no para paginadores numerados.
Concepto 4 — Caching (cache-aside + invalidación)
Idea núcleo: guardar resultados caros en memoria rápida (Redis) para no recalcular. Lo difícil no es cachear: es invalidar.
Entender (capa 1)
Texto: patrón cache-aside: la app consulta el cache; si falla (miss), va a la DB y guarda el resultado en el cache. En writes, hay que invalidar o actualizar la entrada, o servís datos viejos.
Visual:
flowchart TD
R[read] --> C{¿en cache?}
C -->|hit| RET[devuelve del cache]
C -->|miss| DB[lee de DB] --> SET[guarda en cache con TTL] --> RET
W[write] --> UPD[actualiza DB] --> INV[invalida/actualiza cache]
Video: Caching Techniques Explained: Write-Through, Write-Back, Cache-Aside — Hussein Nasser
Ejemplo:
def get_user(uid):
if (u := cache.get(f"user:{uid}")) is not None:
return u # hit
u = db.get_user(uid) # miss
cache.set(f"user:{uid}", u, ttl=300)
return u
def update_user(uid, data):
db.update_user(uid, data)
cache.delete(f"user:{uid}") # invalidar, si no servís datos viejos
Fijar (capa 2)
Nota atómica:
- Front: ¿cuáles son los 2 problemas difíciles del caching?
- Back: (1) invalidación — mantener el cache consistente con la DB en writes; (2) stampede — muchos misses simultáneos pegándole a la DB al expirar. Mitigaciones: invalidar en write, TTL, locks/single-flight.
Feynman: “El cache es una nota pegada en la heladera con el precio de algo. Rápido de leer. El problema es acordarte de cambiar la nota cuando el precio cambia — si no, cobrás mal.”
Aplicar (capa 3)
Meté cache-aside a un endpoint caro con Redis. Verificá hit/miss. Después provocá el bug: cambiá el dato en DB sin invalidar y observá que el endpoint sirve el valor viejo. Arreglalo.
Límites
- No cachees lo que cambia constantemente o lo que debe ser siempre exacto (ej: saldo en un pago).
- TTL corto = menos stale pero más misses; es un trade-off, no hay número mágico.
Concepto 5 — Colas y comunicación async entre servicios
Idea núcleo: en vez de que A llame a B y espere, A pone un mensaje en una cola y B lo procesa cuando puede. Desacopla y absorbe picos.
Entender:
flowchart LR
A[servicio A] -->|publica evento| Q[(cola / Kafka)]
Q -->|consume| B[servicio B]
Q -->|consume| C[servicio C]
- Request-response (sync): A espera a B; si B cae, A falla.
- Event-driven (async): A no espera; si B cae, el mensaje queda en la cola. Video: What is a MESSAGE QUEUE and Where is it used? — Gaurav Sen Nota atómica: Front: ¿qué ganás al pasar de sync a cola? Back: desacople (A no depende de que B esté vivo), absorción de picos (buffer), y varios consumidores del mismo evento. Costo: complejidad, eventual consistency, y hay que manejar duplicados. Feynman: “Sync es llamar por teléfono y esperar que atiendan. Async es dejar un mensaje en el buzón: seguís con tu vida y lo procesan cuando pueden.” Aplicar: publicá un evento a una cola y consumilo desde otro proceso; simulá que el consumidor está caído y verificá que el mensaje no se pierde. Límites: async agrega latencia de extremo a extremo y complejidad de debugging; no lo uses donde necesitás la respuesta ya.
Concepto 6 — Idempotencia
Idea núcleo: una operación idempotente da el mismo resultado si se ejecuta 1 o N veces. Crítica porque las redes reintentan y las colas entregan “al menos una vez”.
Entender (capa 1)
Texto: si un cliente reintenta un pago porque no recibió respuesta, no querés cobrar dos veces. Solución: idempotency key — el cliente manda una clave única; el server registra la clave y, si llega repetida, devuelve el resultado anterior en vez de re-ejecutar.
Visual:
flowchart TD
REQ[request + Idempotency-Key: abc] --> CHK{¿key abc ya procesada?}
CHK -->|sí| PREV[devuelve resultado guardado · no re-ejecuta]
CHK -->|no| EXEC[ejecuta · guarda resultado bajo abc] --> RET[devuelve]
Video: Idempotency in APIs Explained | Why It Matters + Code Example — Arkynate
Ejemplo:
def charge(idempotency_key, amount):
if (prev := store.get(idempotency_key)) is not None:
return prev # ya se hizo, devolver lo mismo
result = payment_provider.charge(amount)
store.set(idempotency_key, result)
return result
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué las APIs de pago exigen idempotency keys?
- Back: porque las redes/colas reintentan (delivery “at least once”); sin la key, un reintento cobra de nuevo. La key deduplica: misma key → mismo resultado, sin re-ejecutar.
Feynman: “Es el sello de mano en la entrada de un boliche: podés pasar el brazo por el lector mil veces, pero solo te cobran la entrada una vez porque ya tenés el sello.”
Aplicar (capa 3)
Agregá una idempotency key a un endpoint de creación. Mandá el mismo request 3 veces y verificá que solo se crea un recurso.
Límites
- Hay que definir cuánto tiempo guardás la key (retención) y qué pasa si dos requests con la misma key llegan concurrentes (lock).
Concepto 7 — Patrones de sistemas distribuidos
Idea núcleo: bajo fallo parcial (un servicio cae, la red corta), estos patrones evitan corrupción y cascadas.
Entender (compacto):
- Retries con backoff: reintentar con espera creciente + jitter (no todos a la vez).
- Circuit breaker: si un servicio falla repetido, dejá de llamarlo un rato (falla rápido) en vez de acumular timeouts.
- Outbox pattern: para publicar un evento Y escribir en DB atómicamente, escribís el evento en una tabla
outboxen la misma transacción; un proceso lo publica después. Evita el “escribí en DB pero no se publicó el evento”. - SAGA: transacción distribuida en pasos, cada uno con su compensación (deshacer) si un paso falla. Reemplaza al commit de dos fases.
- CQRS: separar el modelo de escritura del de lectura (comandos vs queries) cuando tienen necesidades muy distintas.
flowchart LR
subgraph Outbox
TX[(tx: escribe datos + fila outbox)] --> REL[relay lee outbox] --> PUB[publica evento]
end
Nota atómica: Front: ¿qué resuelve el outbox pattern? Back: la atomicidad entre “guardar en DB” y “publicar evento”: ambos no pueden ir en la misma transacción (DB + broker son sistemas distintos), así que se escribe el evento en una tabla outbox dentro de la misma tx y un relay lo publica después. Feynman: “El circuit breaker es el térmico de tu casa: si hay corto, corta la luz de una en vez de dejar que se queme todo el cableado.” Aplicar: implementá retries con backoff exponencial + jitter en un cliente HTTP; simulá un servicio caído y observá el patrón. Límites: cada patrón agrega complejidad; no metas SAGA/CQRS si un monolito con transacciones te resuelve.
Concepto 8 — Diseño de software (SOLID, capas, hexagonal)
Idea núcleo: separar responsabilidades para que el cambio sea barato y testeable.
Entender (compacto):
- SOLID: SRP (una razón para cambiar), OCP (abierto a extensión, cerrado a modificación), LSP, ISP, DIP (depender de abstracciones).
- Arquitectura por capas: config → routes → controllers → services → repositories → middleware.
serversolo hace bootstrap. - Hexagonal / ports & adapters: el núcleo de negocio no conoce la DB ni el framework; se comunica por interfaces (ports) que los adapters implementan. Cambiás Postgres por Mongo tocando un adapter, no el negocio.
flowchart LR
HTTP[adapter HTTP] --> PORT[[port de entrada]] --> CORE[núcleo de negocio] --> PORT2[[port de salida]] --> DBA[adapter DB]
Nota atómica: Front: ¿qué desacopla la arquitectura hexagonal? Back: el núcleo de negocio de los detalles de infraestructura (DB, framework, API externa) vía puertos/interfaces; los adapters implementan esos puertos. Permite testear el negocio sin DB y cambiar infra sin tocar la lógica. Feynman: “El negocio es el motor; los adapters son los enchufes. Cambiás de toma de corriente (DB) sin rediseñar el motor.” Aplicar: refactorizá un endpoint que mezcla lógica + SQL en capas (controller / service / repository); testeá el service mockeando el repository. Límites: en un CRUD chico, tanta capa es over-engineering; escalá la estructura al tamaño real. Recurso (catálogo profundo de patrones): refactoring.guru/es/design-patterns — los 22 patrones GoF (creacionales/estructurales/de comportamiento) con problema, solución, analogía real, UML y código en 10 lenguajes; además su sección de code smells → refactoring. Úsalo como diccionario; acá el foco es cuándo/por qué aplicarlos en un backend, no memorizarlos.
Concepto 9 — APIs modernas (más allá del REST-sync)
Idea núcleo: el REST síncrono básico es el piso; el mercado espera async, real-time y contract-first.
Entender (compacto):
- ASGI (vs WSGI): servidor async, base de FastAPI — maneja miles de conexiones concurrentes I/O-bound.
- WebSockets / SSE: para real-time (chat, notificaciones, progreso) donde el server empuja al cliente.
- Versionado de API:
/v1/, deprecación ordenada, no romper clientes. - OpenAPI-first / contract-first: el contrato (schema) define la API; se generan docs y clientes. FastAPI lo da casi gratis.
- Webhooks: tu API notifica a terceros por HTTP ante eventos (con firma + reintentos + idempotencia).
Nota atómica: Front: ¿WebSocket o SSE? Back: SSE = server→cliente unidireccional, simple, sobre HTTP (notificaciones, feeds). WebSocket = bidireccional, full-duplex (chat, colaboración en vivo). Usá el más simple que resuelva.
Feynman: “REST es mandar cartas de ida y vuelta. WebSocket es una llamada telefónica abierta donde ambos hablan cuando quieren.”
Aplicar: agregá un endpoint SSE que empuje progreso de una tarea larga al cliente; después versioná la API en
/v1/. Límites: WebSockets son más caros de escalar y operar; no los uses si SSE o polling alcanzan.
Concepto 10 — Lógica en la base de datos (functions, views, triggers, RLS)
Idea núcleo: mover lógica de negocio cerca del dato (functions, views, materialized views, triggers, y sobre todo Row-Level Security) gana performance y consistencia a prueba de bugs de app — al costo de testabilidad y portabilidad. Es el trade-off que las ofertas senior premium piden explícitamente (“move business logic closer to the data layer”).
Entender (capa 1)
Texto: hay dos filosofías. App-centric (la default de la mayoría de equipos): la DB es storage tonto, toda la lógica vive en el código — fácil de testear, fácil de portar, pero cada invariante depende de que todos los servicios/devs se acuerden de aplicarla. DB-centric: parte de la lógica vive en la DB — se ejecuta siempre, sin importar qué servicio, script o dev olvidado la salte. No es “una mejor que otra”: es dónde conviene poner qué lógica.
Piezas del arsenal DB-centric en Postgres:
- Functions (
FUNCTION/PROCEDURE): lógica reusable ejecutada en la DB (cálculos, validaciones complejas, batch updates). Evitan mandar y traer datos de ida y vuelta para operaciones que son puramente de datos. - Views: una query guardada que se re-ejecuta en cada SELECT — simplifican joins repetidos, ocultan complejidad, no ganan performance por sí solas.
- Materialized views: como una view pero con el resultado persistido en disco; se refresca manual o programado (
REFRESH MATERIALIZED VIEW). Cambian una agregación cara de “recalcular en cada request” a “leer precalculado” — el costo es que puede estar stale entre refreshes. - Triggers: código que la DB ejecuta automáticamente ante un INSERT/UPDATE/DELETE (auditoría, invariantes, sincronizar una columna derivada). Corren aunque el cambio venga de una migración manual, un script, otro servicio — cosa que la validación en la app no puede garantizar.
- Row-Level Security (RLS): el pilar para aislamiento multi-tenant. Habilitás RLS en una tabla y definís policies que Postgres aplica automáticamente en cada query — filtra filas por tenant sin que el código de la app tenga que acordarse de poner
WHERE tenant_id = ?en cada consulta. Es defense in depth: si un dev olvida el filtro en una query, o hay un bug en el ORM, o un endpoint nuevo se salta el middleware de tenant, RLS igual bloquea el acceso cruzado — porque vive un nivel más abajo que el código que puede fallar. - Supabase Edge Functions: functions (Deno/TS) desplegadas cerca de la DB, invocadas por HTTP o por triggers de la propia base — el patrón que muchas ofertas premium combinan con RLS: la DB decide qué fila ves, la Edge Function corre cerca del dato para lógica que sí necesita código imperativo (llamar una API externa, enviar un email) sin el roundtrip completo a un backend separado.
RLS funciona seteando un identificador de sesión (tenant/usuario) al abrir la conexión — típicamente desde un claim del JWT — y la policy compara ese valor contra la columna tenant_id de cada fila. Para que sea fail-closed usá current_setting('app.tenant_id', true) (el true = missing_ok): si nadie seteó el parámetro, devuelve NULL y la policy no matchea ninguna fila. ⚠️ Ojo: sin ese segundo argumento, Postgres lanza error si el parámetro no existe en la sesión (no devuelve cero filas silenciosamente).
Visual:
flowchart TD
REQ[request con JWT · tenant_id=42] --> APP[app: valida JWT]
APP --> SET["SET LOCAL app.tenant_id = 42<br/>(por conexión/transacción)"]
SET --> Q["SELECT * FROM invoices<br/>(sin WHERE tenant_id!)"]
Q --> RLS{RLS policy:<br/>tenant_id = current_setting}
RLS -->|filtra automático| ROWS[solo filas del tenant 42]
BUG[bug en la app: olvidó el WHERE] -.->|igual protegido| RLS
Video: Implement Authorization using Row Level Security (RLS) with Supabase — Supabase
Ejemplo:
-- ❌ Aislamiento SOLO en la app: un WHERE olvidado = fuga de datos entre tenants
SELECT * FROM invoices WHERE customer_id = 55;
-- si alguien olvida "AND tenant_id = :current_tenant" en ESTA query
-- o en cualquier query nueva del futuro, ve facturas de otro tenant.
-- ✅ RLS: el aislamiento vive en la DB, no depende de que la app se acuerde
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id', true)::int); -- `true` = fail-closed si no está seteado
-- en cada transacción, la app setea el tenant desde el JWT (SET LOCAL vive dentro de la tx):
BEGIN;
SET LOCAL app.tenant_id = '42';
-- a partir de acá, CUALQUIER query a invoices —aunque no tenga WHERE—
-- solo puede ver/tocar filas de tenant_id = 42. Un bug en la app
-- ya no filtra datos entre clientes.
SELECT * FROM invoices; -- Postgres agrega el filtro solo
-- Materialized view: agregación cara precalculada
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT tenant_id, date_trunc('month', created_at) AS month, sum(amount) AS total
FROM invoices GROUP BY tenant_id, month;
REFRESH MATERIALIZED VIEW monthly_revenue; -- corre en cron, no en cada request
-- Trigger: invariante que corre pase lo que pase, sin depender del código que llame
CREATE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_invoices_updated_at
BEFORE UPDATE ON invoices
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué RLS se considera “defense in depth” en vez de solo una optimización?
- Back: porque el filtro por tenant vive en la DB, no en el código de la app — si un dev olvida el
WHERE tenant_id, un ORM tiene un bug, o un endpoint nuevo se salta el middleware, la policy de RLS igual bloquea filas de otros tenants. Protege incluso cuando la capa de arriba falla.
Feynman: “RLS es un portero parado en cada mesa del restaurante, no uno solo en la puerta. Aunque el mesero se equivoque y lleve la cuenta a la mesa equivocada, el portero de esa mesa no deja que nadie que no sea el dueño la vea. No depende de que el mesero se acuerde de nada.”
Aplicar (capa 3)
Creá una tabla invoices multi-tenant, habilitá RLS, definí la policy por tenant_id, seteá app.tenant_id por sesión. Después escribí a propósito una query “con bug” (sin WHERE tenant_id) y verificá que igual no ves filas de otro tenant. Agregá una materialized view para una agregación y medí el tiempo de leerla precalculada vs recalcularla al vuelo.
Límites
- Testabilidad: lógica en functions/triggers no se testea con mocks/unit tests normales — necesitás una DB real (o testcontainers) para cada test, más lento y más setup.
- Portabilidad / lock-in: functions en
plpgsql, triggers y RLS policies son específicos de Postgres — migrar a otro motor (o incluso a otro proveedor gestionado con restricciones) implica reescribir esa capa. - Debugging opaco: un trigger dispara efectos invisibles en el código de la app — un
UPDATEque “misteriosamente” también cambia otra tabla es difícil de rastrear si no está documentado; el stack trace no te lleva ahí como con una excepción en Python. - Lógica repartida en dos lugares: si parte de las reglas vive en la app y parte en la DB, hace falta disciplina (docs, naming, un solo dueño por regla) para no perder el “single source of truth”.
- ⚠️ RLS se saltea para el owner de la tabla y superusers (por default). Si la app conecta con el rol dueño de la tabla, las policies NO aplican y el aislamiento multi-tenant es ilusorio. Solución: conectar con un rol no-owner con permisos mínimos, o forzar con
ALTER TABLE ... FORCE ROW LEVEL SECURITY. Sin esto, el “defense in depth” no existe. - Cuándo sí: invariantes de seguridad críticas (RLS multi-tenant), agregaciones pesadas leídas con frecuencia (materialized views + refresh programado), consistencia que no puede depender de que la app “se acuerde” (triggers de auditoría, columnas derivadas).
- Cuándo no: lógica de negocio que cambia seguido, necesita tests unitarios rápidos, o tiene que ser legible por cualquier dev junior leyendo solo el repo de la app — ahí va en el código, no en la DB.
Checklist de dominio M2
Marcás cuando podés explicar (Feynman) + aplicar:
- Índices / EXPLAIN / N+1
- Transacciones y niveles de aislamiento (ACID)
- Paginación cursor vs offset
- Caching (cache-aside + invalidación)
- Colas / async entre servicios
- Idempotencia
- Patrones distribuidos (retries, circuit breaker, outbox, SAGA, CQRS)
- Diseño de software (SOLID, capas, hexagonal)
- APIs modernas (ASGI, WS/SSE, versionado, OpenAPI, webhooks)
- Lógica en la DB (functions, views, materialized views, triggers, RLS multi-tenant)
- Framework de resolución 6 pasos (clarificar→APIs→arquitectura→profundizar→tradeoffs→cuellos)
- Sub-roadmap System Design 4 fases (ver mapa)
Salida verificable del módulo: una API con arquitectura por capas, 1 query optimizada con índice (EXPLAIN antes/después), 1 cache-aside con invalidación, idempotency key en el endpoint de creación, y un diagrama Mermaid del system design defendible pieza por pieza.