Paso 5 · Bloque III · Unidad 3.1
Divide la aplicación sin romper sus rutas
Modulariza una API ya probada con APIRouter, un punto de composición y un grafo de imports dirigido, conservando su contrato observable.
1. Congela el comportamiento antes de moverlo#
Esta unidad no añade una funcionalidad. Cambia la organización interna de la API construida en el Bloque II y exige que un consumidor no note el cambio. Primero ejecuta los tests de status y body que ya tienes; después captura también la forma pública de OpenAPI:
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
def public_operations(app) -> set[tuple[str, str]]:
paths = app.openapi()["paths"]
return {
(method.upper(), path)
for path, path_item in paths.items()
for method in path_item
if method in HTTP_METHODS
}
def test_public_operations_are_preserved() -> None:
assert public_operations(app) == {
("GET", "/health"),
("GET", "/records/{record_id}"),
("POST", "/records"),
}
La comprobación de operaciones no sustituye los tests HTTP: un path puede seguir existiendo y devolver otro status, otro body o un campo interno. Usa ambos niveles. El conjunto OpenAPI detecta en especial un router que dejó de registrarse o un prefijo añadido por accidente.
2. Separa por una presión concreta#
Un archivo largo no es por sí mismo un defecto. La separación empieza a pagar su coste cuando ya existe una frontera reconocible:
- varias operaciones comparten un prefijo y un vocabulario;
- el punto de arranque mezcla composición con detalles de recursos;
- dos grupos cambian por motivos distintos;
- localizar una operación o revisar su contrato exige recorrer código ajeno;
- los imports empiezan a ocultar qué parte crea la aplicación.
| Señal | Movimiento pequeño | Movimiento prematuro |
|---|---|---|
| Operaciones de expedientes cohesionadas. | Extraer un router de expedientes. | Crear repositorio, servicio y cinco interfaces sin persistencia. |
| Modelos públicos reutilizados. | Moverlos a un módulo de schemas del dominio. | Crear un paquete global de tipos sin propietario. |
| El arranque registra muchos grupos. | Introducir un router agregador. | Ocultar la composición mediante registro automático. |
3. APIRouter agrupa; include_router compone#
APIRouter declara operaciones igual que una aplicación pequeña, pero no escucha por sí solo. Sus operaciones pasan a formar parte de la aplicación cuando otro router o FastAPI las incluye.
# app/api/routes/records.py
from fastapi import APIRouter
router = APIRouter(prefix="/records", tags=["records"])
@router.get("/{record_id}")
def read_record(record_id: str) -> dict[str, str]:
return {"id": record_id}
# app/main.py
from fastapi import FastAPI
from app.api.routes import expedientes
app = FastAPI()
app.include_router(expedientes.router)
La ruta final suma los prefijos y el path de la operación:
include prefix + router prefix + operation path
"" + "/records" + "/{record_id}"
= GET /records/{record_id}
tags, responses y dependencies también se combinan. Una etiqueta cambia la documentación, no la URL. Un prefijo sí cambia la URL. Antes de ejecutar, escribe la ruta final de cada operación: esa predicción descubre barras duplicadas, versiones añadidas y paths desplazados.
4. Una primera estructura de varios archivos#
Empieza con una división que puedas explicar, no con una plantilla universal:
app/
├── __init__.py
├── main.py # crea FastAPI y compone
├── data.py # estado temporal del ejercicio
├── schemas/
│ ├── __init__.py
│ └── expediente.py # contratos Pydantic del recurso
└── api/
├── __init__.py
├── router.py # agrega routers publicados
└── routes/
├── __init__.py
└── expedientes.py # frontera HTTP del recurso
El router agregador hace visible la composición sin cargar main.py con cada detalle:
# app/api/router.py
from fastapi import APIRouter
from app.api.routes import expedientes
api_router = APIRouter()
api_router.include_router(expedientes.router)
# app/main.py
from fastapi import FastAPI
from app.api.router import api_router
app = FastAPI(title="Internal Records")
app.include_router(api_router)
@app.get("/health", tags=["operations"])
def health() -> dict[str, str]:
return {"status": "ok"}
Mantener /health junto al punto de entrada es defendible si representa salud de toda la aplicación. También sería defendible un router operacional cuando existan varias operaciones de plataforma. La estructura nace de responsabilidades presentes, no de una cuota de carpetas.
5. Refactoriza en cortes reversibles#
Usa esta secuencia como andamiaje inicial, no como receta eterna:
- ejecuta tests HTTP y captura operaciones OpenAPI;
- crea el paquete destino sin modificar comportamiento;
- mueve los schemas y corrige imports;
- extrae un solo grupo a
APIRouterpreservando paths y decoradores; - inclúyelo explícitamente en el punto de composición;
- ejecuta regresión y compara OpenAPI;
- elimina el código anterior solo cuando no queden dos registros.
6. Diagnostica por la primera frontera rota#
| Observación | Hipótesis prioritaria | Comprobación corta |
|---|---|---|
| La aplicación no importa. | Módulo, paquete o ciclo de imports. | uv run python -c "from app.main import app; print(app.title)" |
| Arranca, pero una ruta devuelve 404 y no aparece en OpenAPI. | Router no incluido o include no alcanzado. | Inspecciona app.openapi()["paths"] y el punto de composición. |
| La ruta aparece con otro prefijo. | Suma de prefijos modificada. | Anota prefijo de include, router y operación. |
| Hay dos operaciones equivalentes. | Código antiguo y router nuevo registrados a la vez. | Busca decoradores y compara método/path. |
| El body cambió aunque el path existe. | Modelo de salida o implementación movidos con cambios. | Ejecuta el test HTTP del caso exacto. |
7. Diseña un grafo de imports dirigido#
El punto de entrada conoce la composición; los módulos internos no necesitan conocer la instancia app:
main.py
└── api/router.py
└── api/routes/records.py
├── schemas/record.py
└── data.py
Si expedientes.py importa app desde main.py para registrar su decorador, aparece la vuelta del ciclo: main → expedientes → main. Python puede informar que un módulo está “partially initialized” porque intenta leer un nombre antes de terminar de ejecutar el archivo que lo define.
La reparación conceptual es eliminar la vuelta:
- el módulo de rutas exporta
router; main.pyo el agregador importa ese router;- el nivel inferior no importa el punto de entrada;
- el estado temporal tiene un propietario explícito y tampoco importa
main.py.
8. Decisiones y límites de esta unidad#
Puedes agrupar por funcionalidad (expedientes, usuarios) o por tipo técnico (routes, schemas) mientras el repositorio sea pequeño. Aquí usamos una combinación mínima porque mantiene próximos el contrato y su frontera HTTP, y conserva un punto de composición legible.
Esta unidad no introduce servicios, repositorios, base de datos, inyección de recursos, settings, autenticación ni auto-registro de routers. Tampoco afirma que cada dominio deba tener el mismo árbol. La siguiente unidad decidirá cómo compartir recursos y configurar entornos; anticiparla ahora diluiría el mecanismo que estamos comprobando.
La condición de parada es precisa: un grupo cohesivo vive en su router, los schemas tienen propietario, los imports no forman ciclos, el registro es explícito y la regresión conserva el contrato. Cuando eso se cumple, deja de mover archivos.
9. Criterios de dominio#
- Predices la ruta final combinando los tres segmentos de path.
- Explicas la diferencia entre declarar operaciones e incluir un router.
- Conservas status, body, filtrado, errores y operaciones OpenAPI durante el refactor.
- Localizas un router ausente a partir de un 404 y de
/openapi.json. - Dibujas un grafo de imports sin vuelta hacia
main.py. - Justificas qué separaste por una presión observable y qué dejaste junto.
- Repites la capacidad sobre otro dominio sin seguir el orden de archivos de esta explicación.
Prácticas relacionadas#
- EX-B3-01 · Extraer el primer router sin cambiar el contrato
- EX-B3-S01-01 · Checkpoint: separar routers y schemas