Atlas

Python Web Frameworks: arquitectura, middleware, routing

Qué hay dentro de un framework web: la Application como grafo de objetos (route table + stack de middleware + sistema de resolución de dependencias + abstracción request/response), cómo un request se despacha internamente hasta tu handler, cómo el router hace matching, cómo se resuelve la inyección de dependencias, cómo elegir framework por requisitos, y cómo el framework integra la seguridad básica (access token, secrets, RBAC, CSRF, service account/IAM).

Teoría

Qué es “un framework por dentro” y por qué importa a nivel senior

Un framework web no es magia: es un grafo de objetos con responsabilidades bien separadas que, entre que llega el request parseado desde el servidor (WSGI/ASGI) y tu función handler, hace siempre lo mismo. A nivel senior la pregunta no es “¿sé usar FastAPI?”, es “¿puedo describir la arquitectura interna de un framework y justificar por qué está partida así?”. Todo framework maduro (Flask, Django, FastAPI/Starlette) se descompone en cinco piezas:

  1. Application object — el objeto raíz (Flask(__name__), FastAPI(), el WSGIHandler/ASGIHandler de Django). Es el callable que el servidor invoca por cada request, y el contenedor que registra todo lo demás: rutas, middleware, config, hooks de ciclo de vida.
  2. Router / route table — la estructura que mapea (método, path) a un endpoint. Registra rutas al arrancar y las resuelve en cada request.
  3. Middleware stack — la cadena de capas que envuelve el dispatch (modelo “onion”; tratado a fondo en la lección de WSGI/ASGI). Aquí interesa cómo el framework lo registra y lo compone, no el modelo en sí.
  4. Request/Response abstraction — objetos que envuelven el environ/scope crudo en una API ergonómica (request.headers, request.json(), Response(...)). Aíslan tu código del protocolo de transporte.
  5. Sistema de extensión / inyección de dependencias — cómo se conectan recursos transversales (DB session, usuario autenticado, config) a los handlers sin que estos los construyan a mano.

El flujo interno que comparten todos:

servidor (WSGI/ASGI)
   │  request cruda (environ / scope+receive+send)

Application.__call__            ← el framework es este callable

   ├─▶ middleware stack (onion): entra por la capa externa

   ├─▶ ROUTER.match(method, path) ──▶ endpoint + path params  (o 404/405)

   ├─▶ resolución de dependencias (DI): construir lo que el handler pide

   ├─▶ (de)serialización + validación del body/params

   ├─▶ HANDLER (tu función)  ──▶ valor de retorno

   └─▶ construir Response ──▶ sube por el middleware ──▶ servidor ──▶ cliente

Entender esto permite responder las tres preguntas trampa de una entrevista senior: dónde se decide el 404 (router, antes del handler), cuándo corre la autenticación (middleware o dependencia, antes del handler), y quién construye la DB session (el sistema de DI, no el handler).

El Application object: un callable que es también un registro

La confusión clásica: creer que app “es el servidor”. No. app es el callable de la interfaz (WSGI: app(environ, start_response); ASGI: async app(scope, receive, send)) que el servidor invoca. Su segundo rol es ser el registro central: los decoradores @app.get(...), @app.middleware(...), app.add_middleware(...) no ejecutan nada en el momento — mutan estructuras de datos dentro de app (la route table, la lista de middleware). El trabajo real ocurre en el __call__, una vez por request.

# El decorador de ruta NO llama al handler: lo REGISTRA en la route table.
# En "import time" se construye el grafo; en "request time" se recorre.
app = FastAPI()

@app.get("/orders/{order_id}")          # ← registra Route(path, endpoint) en app.router.routes
async def get_order(order_id: int):     # ← este cuerpo solo corre cuando llega un request que matchea
    ...

Esta separación import-time (construcción del grafo) vs request-time (recorrido del grafo) es el modelo mental que hay que tener. Explica por qué mover trabajo pesado a import-time (compilar regex de rutas, construir el árbol de dependencias) hace que el request-time sea barato, y por qué un @app.get mal puesto dentro de una función “no registra nada” si esa función nunca corre.

Routing por dentro: cómo se resuelve (método, path) → endpoint

El router tiene dos momentos: registro (al arrancar) y matching (por request). En el registro, cada ruta se compila: el patrón /orders/{order_id} se convierte en una regex con grupos nombrados y converters que castean ({order_id:int} → matchea \d+ y castea a int). En el matching, el router recibe (method, path) y devuelve (endpoint, path_params) o un fallo (404 si ningún path matchea, 405 si el path matchea pero no el método).

Hay dos estrategias de matching que un senior debe distinguir:

# Starlette/FastAPI: el orden de registro decide el match. Ruta específica ANTES de la genérica.
@app.get("/users/me")          # ✅ debe ir primero
async def read_me(): ...

@app.get("/users/{user_id}")   # si esta va primero, "me" cae aquí como user_id="me" → error de casteo
async def read_user(user_id: int): ...

Puntos senior de routing:

Middleware: cómo el framework lo registra y compone

El modelo onion (cada capa ve el request al bajar y la response al subir) se cubre en profundidad en la lección de WSGI/ASGI. Aquí lo que importa es cómo el framework lo construye internamente: al arrancar, el framework toma la lista de middlewares registrados y los anida como funciones que se envuelven una a otra, produciendo un único callable. Ese anidamiento ocurre una sola vez (import-time), no por request.

# Django construye la cadena UNA vez: cada middleware es una factory get_response -> handler.
# El resultado es f_ext(f_mid(f_int(view))) — se compone al arrancar, se llama por request.
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",     # capa más externa
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
]
# FastAPI/Starlette: add_middleware apila; el ÚLTIMO agregado queda MÁS EXTERNO.
app.add_middleware(GZipMiddleware)            # se ejecuta primero al bajar, último al subir
app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"])

Distinción senior que se pregunta: middleware vs dependencia. Un middleware es transversal a todas las rutas y opera sobre el request/response crudo (CORS, gzip, request-id, tracing). Una dependencia es selectiva por endpoint y opera sobre valores ya parseados (obtener el usuario autenticado, abrir una DB session). Regla: si aplica a todo y no necesita el modelo de datos parseado → middleware; si aplica a algunos endpoints y produce un valor que el handler consume → dependencia. Meter auth por-endpoint en un middleware global es un olor de diseño (obliga a listas de exclusión frágiles).

Inyección de dependencias: el diferenciador arquitectónico

La inyección de dependencias (DI) es “el handler declara qué necesita, no cómo construirlo; el framework lo provee”. Es lo que separa un handler testeable y desacoplado de uno que instancia su propia DB session y su propio cliente HTTP a mano (imposible de mockear, acoplado a la infraestructura).

FastAPI es el caso más explícito en Python: la DI se hace por la firma de la función. Declaras un parámetro = Depends(callable) y el framework, en request-time:

  1. Inspecciona la firma del handler y descubre el árbol de dependencias (una dependencia puede pedir otras → sub-dependencias, un DAG).
  2. Resuelve en orden topológico, llamando cada dependencia (y cacheando su resultado dentro del request: la misma dependencia usada en tres sitios se ejecuta una vez por request, use_cache=True por defecto).
  3. Inyecta los resultados como argumentos y llama al handler.
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession

app = FastAPI()

# Dependencia con setup/teardown vía yield: lo de ANTES del yield es setup,
# lo de DESPUÉS corre cuando la response ya se envió (cierre garantizado, como un context manager).
async def get_db() -> AsyncSession:
    session = SessionLocal()
    try:
        yield session            # ← el valor inyectado
    finally:
        await session.close()    # ← teardown post-response

# Sub-dependencia: get_current_user DEPENDE de get_db. FastAPI resuelve el DAG.
async def get_current_user(db: AsyncSession = Depends(get_db)) -> User:
    ...

@app.get("/orders")
async def list_orders(
    user: User = Depends(get_current_user),   # el handler solo declara QUÉ necesita
    db: AsyncSession = Depends(get_db),        # get_db se resuelve UNA vez y se comparte (cache)
):
    return await orders_repo.for_user(db, user.id)

Propiedades senior de este modelo:

Cómo despacha cada framework (comparación interna)

PiezaFlaskDjangoFastAPI (sobre Starlette)
Callable baseWSGI app(environ, start_response)WSGI/ASGI handlerASGI app(scope, receive, send)
RouterWerkzeug Map/Rule (matcher por especificidad)URLResolver sobre urlpatterns (regex/route)lista ordenada de Route (regex, linear scan)
Estado por requestrequest/g como proxies context-local (ContextVar)objeto HttpRequest pasado explícitoobjeto Request + DI por firma
DIno built-in (extensiones + g)no built-in (settings + apps registry)Depends, DAG, cache, yield, overrides
Validación/serializaciónmanual (o extensión)Forms / DRF serializersPydantic integrado en la firma

Insight: FastAPI = Starlette (toolkit ASGI: routing + middleware + request/response) + Pydantic (validación/serialización) + un sistema de DI. No reinventa el servidor ni la interfaz; ensambla piezas. Saber esto responde “¿qué es FastAPI internamente?”: una capa de DI + validación por firma encima del toolkit ASGI Starlette.

Patrones de capas: dónde encaja la DI

El anti-patrón de junior es el fat handler: el endpoint parsea, valida reglas de negocio, arma SQL y serializa, todo en una función. No se testea sin levantar HTTP+DB, y la lógica no se reutiliza. La arquitectura senior separa responsabilidades en capas, y la DI es el pegamento entre ellas:

Router / handler (presentación)   → traduce HTTP ↔ dominio; delgado. Sin reglas de negocio.
        │  (DI inyecta el service)
Service (dominio / casos de uso)  → orquesta reglas de negocio; no sabe de HTTP ni de SQL.
        │  (DI inyecta el repository)
Repository (acceso a datos)       → traduce dominio ↔ persistencia; el único que toca la DB.
# Handler DELGADO: solo traduce HTTP y delega. La DI le entrega el service ya construido.
@app.post("/orders", status_code=201)
async def create_order(
    body: OrderCreate,
    service: OrderService = Depends(get_order_service),  # DI construye service (que trae su repo)
):
    order = await service.place_order(body)   # toda la regla de negocio vive aquí, testeable sin HTTP
    return OrderResponse.model_validate(order)

Beneficio concreto: el OrderService se testea unitariamente con un repo falso, sin cliente HTTP ni DB. El handler se testea con dependency_overrides. Cada capa se prueba en aislamiento — imposible con el fat handler.

Cómo seleccionar el framework según requisitos

No hay “el mejor framework”: hay el que ajusta a los requisitos y restricciones. Ejes de decisión senior:

EjeFlaskDjangoFastAPI
Filosofíamicro, ensambla túbatteries-includedmicro + validación/DI
ConcurrenciaWSGI sync (async parcial)WSGI/ASGI (async creciente)ASGI async nativo
ORM incluidono (SQLAlchemy aparte)sí (Django ORM)no (SQLAlchemy/otro)
Admin / auth / migracionesno (extensiones)sí (admin, auth, migrations)no
Validación / OpenAPImanualDRF (con esfuerzo)Pydantic + OpenAPI auto
Curvabajamedia-altabaja-media

Mapeo requisito → elección:

La decisión es arquitectónica y cara de revertir (condiciona ORM, modelo de concurrencia, ecosistema, contratación). Se justifica con requisitos, no con moda. Regla: elige por lo que el proyecto necesita en 12 meses, no por el benchmark de “hello world”.

Seguridad básica integrada en el framework

El framework es donde se cablean los controles de seguridad. La profundidad de OWASP (XSS/SQLi/CSRF a fondo) vive en otra lección; aquí interesa cómo el framework integra cada control.

Access token (Bearer / OAuth2). El patrón integrado es una dependencia de seguridad: extrae el token del header Authorization: Bearer <jwt>, lo valida (firma + expiración), y devuelve el principal (usuario). Un token que falla → 401 con WWW-Authenticate: Bearer. Al ser una dependencia, se compone (RBAC la usa como sub-dependencia) y aparece en el OpenAPI como esquema de seguridad.

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")  # declara el esquema; extrae el Bearer

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    try:
        payload = jwt.decode(token, SETTINGS.jwt_public_key, algorithms=["RS256"])
    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
            headers={"WWW-Authenticate": "Bearer"},   # requerido por el estándar en 401 Bearer
        )
    return await users_repo.get(payload["sub"])

Secrets manager. El principio: los secretos nunca en el código ni en el repo. El framework los recibe por configuración inyectable. En dev, variables de entorno (.env); en prod, un secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) del que la app lee al arrancar. pydantic-settings es el patrón idiomático: una BaseSettings que carga de env y valida tipos, inyectable como dependencia.

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")   # dev: .env · prod: env inyectada por el secrets manager
    database_url: str
    jwt_public_key: str
    # NUNCA un default con el valor real del secreto en el código

SETTINGS = Settings()   # falla al arrancar si falta un secreto → fail-fast, no en el primer request

Diferencia local vs prod (pregunta A3): mismo código, distinta fuente de config — en dev el .env local; en prod la variable la inyecta el orquestador/secrets manager. El código no cambia; cambia de dónde vienen los valores. Nunca commitear el .env.

Roles / RBAC. La autorización (¿puede este usuario hacer esto?) se implementa como dependencia que compone sobre la autenticación (¿quién es?). Falla de permiso → 403 (distinto de 401: 401 = no sé quién eres; 403 = sé quién eres y no puedes). FastAPI ofrece Security + SecurityScopes para RBAC por scopes; Django trae un sistema de permissions y grupos.

def require_role(role: str):
    # factory de dependencia: devuelve una dependencia que exige un rol
    async def checker(user: User = Depends(get_current_user)) -> User:
        if role not in user.roles:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
        return user
    return checker

@app.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: int, user: User = Depends(require_role("admin"))):
    ...   # solo llega aquí si el token es válido (401 si no) Y el rol es admin (403 si no)

CSRF. Relevante solo con autenticación por cookie de sesión (credencial ambient que el browser adjunta sola en requests cross-site). Con Bearer token en header no hay CSRF: el token no se envía solo, y JS de otro origin no puede leerlo (Same-Origin Policy) — por eso las APIs stateless son inmunes por diseño. Django integra CsrfViewMiddleware (token por sesión) para su modelo cookie-based; Starlette/FastAPI no lo traen porque su default es Bearer. Regla: si usas cookie de sesión, activa CSRF y pon HttpOnly+Secure+SameSite; si usas Bearer, el problema no existe. (Detalle del mecanismo en la lección de seguridad OWASP.)

Service account / IAM. Distinto de la auth de usuario: es cómo la app misma se autentica ante otros servicios (DB gestionada, bucket, otra API, un cloud). El anti-patrón es una service account key (archivo JSON con credenciales de larga vida) commiteada o rotada a mano. El patrón senior es workload identity / IAM roles: el runtime (pod de k8s, tarea ECS, Cloud Run) asume una identidad IAM y obtiene credenciales temporales y auto-rotadas del proveedor, sin secreto persistente. Principio transversal: least privilege — la identidad de la app solo tiene los permisos que ese servicio necesita, nada más. Así, aunque la app se comprometa, el radio de daño es mínimo.

Ejercicios

1. Ordenar rutas para que el matching sea correcto

Tienes estas rutas en un router de linear-scan (Starlette/FastAPI). Un GET a /users/me responde un error de validación en vez del perfil propio. ¿Por qué, y cómo lo arreglas?

@app.get("/users/{user_id}")
async def read_user(user_id: int): ...

@app.get("/users/me")
async def read_me(): ...
Solución

El router prueba las rutas en orden de registro y se queda con el primer match. /users/{user_id} está registrada primero y matchea /users/me, capturando user_id="me"; al castear a int falla → error de validación (422). La ruta específica nunca se alcanza.

Arreglo: registrar la ruta específica antes que la genérica.

@app.get("/users/me")            # ✅ específica primero
async def read_me(): ...

@app.get("/users/{user_id}")     # genérica después
async def read_user(user_id: int): ...

Lección senior: en routers de linear-scan el orden es semántica. En routers por especificidad (Werkzeug) el motor prioriza la regla más específica automáticamente, pero no asumas eso en Starlette. Regla general: rutas literales antes que las paramétricas.

2. Contar cuántas veces se ejecuta una dependencia por request

En FastAPI, get_db se usa en el handler y también en dos sub-dependencias que el handler pide. ¿Cuántas sesiones de DB se abren por request? ¿Y si pusieras Depends(get_db, use_cache=False) en un sitio?

async def get_db(): ...                               # yield session
async def get_user(db = Depends(get_db)): ...
async def get_tenant(db = Depends(get_db)): ...
@app.get("/x")
async def h(u = Depends(get_user), t = Depends(get_tenant), db = Depends(get_db)): ...
Solución

Una sola sesión. Por defecto use_cache=True: dentro de un mismo request, FastAPI cachea el resultado de cada dependencia por su cache key (la función + parámetros). get_db aparece 3 veces (vía get_user, get_tenant y directo en el handler) pero se resuelve una vez y las tres referencias comparten la misma session. El teardown (finally: close) corre una vez, tras la response.

Si pones use_cache=False en un sitio, esa referencia fuerza una resolución nueva → se abre una segunda sesión independiente para ese punto, con su propio teardown. Útil solo si necesitas explícitamente un recurso aislado (p. ej. una transacción separada). Casi siempre es un error que duplica conexiones.

Lección: el scope de cache de una dependencia es el request, no la app. Compartir la session dentro del request es lo que permite que service y repo trabajen en la misma transacción.

3. Refactorizar un fat handler a capas con DI

Este handler mezcla HTTP, regla de negocio y SQL. Sepáralo en handler / service / repository e indica qué inyecta la DI.

@app.post("/orders")
async def create_order(body: OrderCreate, db = Depends(get_db)):
    if body.total <= 0:
        raise HTTPException(400, "Invalid total")
    row = await db.execute(insert(orders).values(**body.dict()))
    await db.commit()
    await email_client.send(body.customer_email, "Order placed")
    return {"id": row.inserted_primary_key[0]}
Solución
# --- Repository: único que toca la DB ---
class OrderRepository:
    def __init__(self, db: AsyncSession): self.db = db
    async def insert(self, data: OrderCreate) -> Order:
        row = await self.db.execute(insert(orders).values(**data.model_dump()))
        await self.db.commit()
        return Order(id=row.inserted_primary_key[0], **data.model_dump())

# --- Service: reglas de negocio, sin HTTP ni SQL directo ---
class OrderService:
    def __init__(self, repo: OrderRepository, mailer: Mailer):
        self.repo, self.mailer = repo, mailer
    async def place_order(self, data: OrderCreate) -> Order:
        if data.total <= 0:
            raise DomainError("Invalid total")     # error de dominio, NO HTTPException
        order = await self.repo.insert(data)
        await self.mailer.send(data.customer_email, "Order placed")
        return order

# --- DI: construye la cadena repo→service ---
def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
    return OrderService(OrderRepository(db), Mailer())

# --- Handler: delgado, solo traduce HTTP ↔ dominio ---
@app.post("/orders", status_code=201)
async def create_order(body: OrderCreate, service: OrderService = Depends(get_order_service)):
    try:
        order = await service.place_order(body)
    except DomainError as e:
        raise HTTPException(status_code=422, detail=str(e))   # traducción dominio→HTTP en el borde
    return OrderResponse.model_validate(order)

Claves: el service no lanza HTTPException (no sabe de HTTP; lanza un error de dominio que el handler traduce). La DI inyecta service ya armado con su repo y su mailer. OrderService se testea con un repo y un mailer falsos, sin HTTP ni DB.

4. Decidir el framework por requisitos

Para cada caso elige Flask, Django o FastAPI y justifica en una frase: (a) plataforma interna de gestión con panel admin, roles y reportes, entrega en 6 semanas; (b) API pública de alto tráfico que hace fan-out a 5 microservicios por request; (c) webhook receiver de 3 endpoints que se integra en una app Flask existente.

Solución
  • (a) Django. El admin, el sistema de auth/permisos y las migraciones ya hechos ahorran semanas; el back-office CRUD es exactamente su punto dulce. Construir eso en FastAPI sería reimplementar Django peor.
  • (b) FastAPI. Fan-out a 5 servicios por request es I/O-bound de alta concurrencia: ASGI async atiende muchas conexiones concurrentes por proceso con pocos workers. Pydantic + OpenAPI automático encajan con una API pública que debe documentar su contrato. (Requisito duro: todo el path async, sin llamadas bloqueantes.)
  • (c) Flask. Ya hay una app Flask; añadir 3 endpoints ahí evita introducir un segundo framework, un segundo runtime y complejidad de despliegue. La coherencia del stack pesa más que las features de FastAPI para 3 rutas simples.

Lección: la elección se justifica con requisitos (features necesarias, modelo de concurrencia, coste de integración), no con “cuál es más rápido/moderno”.

5. Elegir el control de seguridad y el status correcto

Para cada situación di qué control usar y qué status devolver: (a) request sin header Authorization; (b) token válido pero el usuario no es admin y pide borrar un recurso; (c) API stateless con Bearer — ¿hace falta protección CSRF?; (d) la app necesita leer credenciales de la DB en producción.

Solución
  • (a) 401 Unauthorized con WWW-Authenticate: Bearer. No hay principal: la autenticación falla. No es 403 (403 implica que sí sé quién eres).
  • (b) 403 Forbidden. La autenticación pasó (sé quién es), falla la autorización/RBAC. Se implementa como dependencia que compone sobre get_current_user y chequea el rol.
  • (c) No. CSRF explota credenciales ambient (cookies que el browser adjunta solo). Un Bearer en header no se envía automáticamente y JS cross-origin no puede leerlo (SOP) → inmune por diseño. CSRF solo aplica si usas cookie de sesión.
  • (d) Secrets manager + IAM. Los secretos no van en el código: se inyectan por config (pydantic-settings desde env), y en prod la fuente es un secrets manager. Mejor aún, la app asume una identidad IAM/workload identity con credenciales temporales de least-privilege en vez de una key de service account de larga vida.

Preguntas tipo entrevista (EN)

1. “What actually happens inside a web framework between the server handing off a parsed request and your handler function running?”

2. “How does FastAPI’s dependency injection work, and how is it different from a classic IoC container?”

3. “Why put authentication in a dependency instead of in a global middleware?”

4. “How do you select between Flask, Django, and FastAPI for a new service?”

5. “Where do you store secrets, and how does that differ between local and production?”

6. “Does a stateless API with Bearer tokens need CSRF protection? Why or why not?”

7. “Two routes, /users/me and /users/{user_id}, and /users/me returns a validation error. What’s wrong?”

Referencias