Blogs / Estructura de Proyectos FastAPI: Organizando Apps Grandes

Estructura de Proyectos FastAPI: Organizando Apps Grandes

Publicado
26 de agosto de 2026
Autor
Faizan Nadeem
Etiquetas
FastAPI Python Backend Development Software Architecture
Un primer plano de la pantalla de una computadora mostrando coloridas líneas de código de programación
Foto de Glen Carrie en Unsplash

Todo proyecto de FastAPI empieza igual: un archivo main.py con un puñado de rutas, uno o dos modelos de Pydantic y una llamada a la base de datos en línea dentro del handler. Funciona. Es rápido de escribir, rápido de leer y, durante las primeras semanas, parece la prueba de que no hace falta pensar demasiado la estructura. Luego el proyecto crece — más rutas, más modelos, una tarea en segundo plano, una dependencia de autenticación compartida entre seis endpoints — y ese único archivo se convierte en un desplazamiento de 2.000 líneas donde encontrar cualquier cosa significa buscar por nombre de función y tener suerte.

El error no es escribir un main.py plano al principio. El error es no tener un plan para el momento en que deje de funcionar. La mayoría de los equipos o esperan demasiado y hacen una reescritura total presa del pánico, o se pasan de rosca desde el primer día con una estructura de carpetas copiada de una app empresarial en Java que añade cinco capas de indirección a un proyecto con tres endpoints. Ninguno de esos extremos es lo que realmente sobrevive al crecimiento. Lo que funciona es una estructura con la que puedas empezar simple y evolucionar de forma deliberada — una que separe responsabilidades antes de que te veas obligado a hacerlo y que evite los errores de importación circular que aparecen justo cuando el código ya es demasiado grande como para desenredarlo con facilidad.

Aprenderás:

  • Cuándo una estructura plana con main.py realmente empieza a fallar y cuáles son las señales concretas a las que debes prestar atención
  • La diferencia real entre una estructura en capas (técnica) y una guiada por dominio (basada en funcionalidades), y cuál encaja con cada equipo
  • Cómo componer routers con APIRouter para que el registro de rutas no se convierta en su propia carga de mantenimiento
  • Por qué los proyectos FastAPI tienen importaciones circulares específicamente, y cuáles son las reglas de dirección de imports que las previenen
  • Una estructura de carpetas completa y con criterio que puedes copiar directamente en un proyecto nuevo
  • Cómo migrar una app plana existente a una estructura escalable sin una reescritura completa

Tabla de contenidos

  1. Lo básico
  2. Estructuras en capas vs guiadas por dominio
  3. Composición de routers con APIRouter
  4. Por qué ocurren las importaciones circulares en apps FastAPI
  5. Una estructura que escala
  6. Código compartido: Core, Schemas y Dependencies
  7. Migrar una app plana existente
  8. Errores comunes
  9. Buenas prácticas de producción
  10. A dónde ir desde aquí

Lo básico

Las señales de que una estructura plana se está rompiendo

Una app FastAPI de un solo archivo no está mal a pequeña escala — es la cantidad adecuada de estructura para el tamaño del problema. La señal para cambiar no es el número de líneas, sino la fricción: abres main.py para añadir un endpoint y tienes que pasar por quince no relacionados para encontrar dónde corresponde; dos personas editando el mismo archivo generan conflictos de merge en cada PR; un modelo de Pydantic usado por tres rutas acaba copiado y pegado en vez de importado porque nadie puede decir rápidamente dónde vive la versión “real”.

Nada de esto son fallos de arquitectura en un sentido abstracto — son fallos de navegación. La solución no es un framework, sino poner el código relacionado junto al código relacionado y dar a los imports una dirección que no vuelva sobre sí misma.

Por qué FastAPI en particular necesita un plan

FastAPI no impone una estructura de proyecto como sí hace Django con apps, y esa flexibilidad es un arma de doble filo. Significa que puedes construir una estructura que encaje con tu equipo, pero también significa que no hay una barandilla que impida que una carpeta routers importe desde services, que a su vez vuelve a importar desde routers para reutilizar un helper — un error al que es más difícil llegar el primer día con los límites de apps de Django. Una aplicación FastAPI grande es, en realidad, solo una aplicación Python grande con una capa web atornillada, así que hereda todos los problemas de empaquetado de Python, más el propio sistema de inyección de dependencias de FastAPI, que también tiene sus propias opiniones estructurales sobre dónde deberían vivir los callables de Depends.

Estructuras en capas vs guiadas por dominio

Hay dos formas dominantes de organizar una base de código FastAPI en crecimiento, y la correcta depende menos del tamaño del proyecto que de cómo trabaja realmente tu equipo en el día a día.

Estructura en capas (técnica)

Una estructura en capas agrupa los archivos por lo que son — todos los routers juntos, todos los schemas juntos, todos los modelos de base de datos juntos:

app/
├── routers/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── schemas/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── models/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── services/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
└── main.py

Esta es la estructura que enseñan la mayoría de los tutoriales de FastAPI, y funciona bien para APIs pequeñas y medianas, especialmente cuando uno o dos ingenieros tocan la mayor parte del código. La desventaja aparece a medida que la app crece: añadir una funcionalidad — por ejemplo, una capacidad de refunds — significa tocar cuatro o cinco carpetas principales distintas para una sola pieza de funcionalidad, y es fácil añadir un schema sin su router correspondiente, o un router que silenciosamente se salta la capa de servicio y habla directamente con la base de datos.

Estructura guiada por dominio (basada en funcionalidades)

Una estructura guiada por dominio agrupa los archivos por lo que hacen para el negocio — todo lo relacionado con orders vive junto, independientemente de si es router, schema o service:

app/
├── users/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── orders/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── payments/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
└── main.py

Esto escala mejor para equipos más grandes y dominios más amplios, porque cada carpeta se parece más a una unidad autocontenida — un nuevo ingeniero trabajando en orders rara vez necesita abrir payments, y la carpeta es un límite natural para la propiedad del código. La contrapartida es más ceremonia inicial para apps realmente pequeñas, y cierta duplicación cuando dos dominios necesitan schemas muy parecidos.

Regla general: empieza con capas si tienes menos de unas diez rutas y uno o dos contribuidores. Una vez superes eso, o cuando una sola funcionalidad atraviese de forma rutinaria cuatro carpetas no relacionadas, cambia a un enfoque guiado por dominio. Las publicaciones públicas de ingeniería de Netflix y la mayoría de las referencias de FastAPI a gran escala convergen en el enfoque guiado por dominio exactamente por esta razón — es la estructura que mantiene bajos los conflictos de merge y los pisotones entre equipos a medida que crece el número de personas, aunque cueste un poco más de estructura el primer día.

Composición de routers con APIRouter

Elijas la estructura que elijas, el mecanismo para conectar las rutas es el mismo: APIRouter. Cada módulo funcional define su propio router en lugar de registrar rutas directamente en la app global FastAPI():

# app/orders/router.py
from fastapi import APIRouter, Depends
from app.orders.schemas import OrderOut, OrderCreate
from app.orders.service import create_order, get_order
from app.core.dependencies import get_current_user

router = APIRouter(prefix="/orders", tags=["orders"])

@router.post("/", response_model=OrderOut)
async def create_order_endpoint(
    payload: OrderCreate,
    user=Depends(get_current_user),
):
    return await create_order(payload, user)

@router.get("/{order_id}", response_model=OrderOut)
async def read_order(order_id: int, user=Depends(get_current_user)):
    return await get_order(order_id, user)

Luego, un único lugar — normalmente app/api.py o app/main.py — compone cada router funcional dentro de la app:

# app/api.py
from fastapi import APIRouter
from app.users.router import router as users_router
from app.orders.router import router as orders_router
from app.payments.router import router as payments_router

api_router = APIRouter()
api_router.include_router(users_router)
api_router.include_router(orders_router)
api_router.include_router(payments_router)
# app/main.py
from fastapi import FastAPI
from app.api import api_router

app = FastAPI(title="My API")
app.include_router(api_router, prefix="/api/v1")

Esto te da tres cosas que una app plana no tiene gratis: un único lugar para ver cada grupo de rutas registrado, un sitio natural para versionar la API (/api/v1, /api/v2 como árboles separados de APIRouter) y tags y prefix por router para que la documentación OpenAPI se mantenga organizada sin boilerplate por ruta. También es la base estructural sobre la que se construye el resto de este grupo — las dependencias que adjuntas a un router aquí son exactamente lo que analizamos en Inyección de dependencias en FastAPI: patrones y anti-patrones.

Por qué ocurren las importaciones circulares en apps FastAPI

Las importaciones circulares son, con diferencia, el bug estructural más común en apps FastAPI en crecimiento, y siguen un patrón predecible: un router importa un service, el service importa un schema y — porque alguien quiso reutilizar un helper de validación — el schema importa algo del módulo del router. Python lanza ImportError: cannot import name 'X' from partially initialized module y la solución parece no tener relación con la causa real.

La causa real casi siempre es la ausencia de una regla de dirección. En una app FastAPI bien estructurada, los imports solo deberían fluir en una dirección:

router → service → models/schemas → core

Un router puede importar un service. Un service puede importar models y schemas. Nada en models, schemas o core debería importar jamás desde un router o un service — esos están arriba del grafo de dependencias, no abajo. Cuando esa regla se rompe, normalmente es porque un schema necesita un tipo que está definido cerca de un router, o porque dos dominios necesitan compartir un helper y alguien importa directamente entre dominios en lugar de promover lo compartido a core.

Tres soluciones concretas cubren casi todos los casos reales:

  1. Promueve el código realmente compartido a core o a un paquete shared dedicado. Si orders y payments necesitan ambos un tipo Money, no pertenece a ninguno de los dos dominios — pertenece a app/core/types.py, y ambos dominios lo importan hacia abajo.
  2. Usa TYPE_CHECKING para referencias cruzadas solo de tipos. Si un schema necesita una pista de tipo desde otro módulo únicamente para anotaciones, protege el import para que no se ejecute en tiempo de ejecución:
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.orders.models import Order

def summarize(order: "Order") -> str:
    ...
  1. Rompe el ciclo con un import local dentro de la función, como último recurso — funciona, pero es una señal de que el límite del módulo está mal, no un patrón al que recurrir por defecto.

La decisión entre capas y guiado por dominio también importa aquí: una estructura guiada por dominio hace que las importaciones entre dominios sean visualmente obvias (from app.payments.service import x dentro de app/orders/), lo que hace mucho más fácil notar — y cuestionar — cuándo se está cruzando un límite de dominio que en una estructura en capas donde todo ya vive en carpetas principales compartidas.

Una estructura que escala

Para una aplicación FastAPI mediana o grande, esta es una estructura que aguanta bien en la práctica, combinando un núcleo guiado por dominio con un pequeño paquete core en capas para preocupaciones genuinamente transversales:

app/
├── main.py                 # crea la instancia FastAPI(), monta api_router
├── api.py                  # APIRouter de nivel superior que compone todos los routers de dominio
├── config.py                # Settings via pydantic-settings, cargados una sola vez
├── core/
│   ├── database.py           # engine, factoría de sesiones
│   ├── security.py           # helpers de JWT/hash de contraseñas
│   ├── dependencies.py       # get_current_user, get_db, Depends compartidos
│   ├── exceptions.py         # clases de excepción personalizadas + handlers
│   └── logging.py
├── users/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── orders/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
└── tests/
    ├── users/
    └── orders/

Cada carpeta de dominio es autocontenida e importable de forma aislada — podrías borrar orders/ por completo y users/ seguiría funcionando. core se sitúa por debajo de cada dominio y nunca importa desde uno, que es lo que hace que todo el grafo sea acíclico. Esta es la estructura a la que se hace referencia en el resto de este grupo: la dependencia get_db en core/dependencies.py es donde se conectan las sesiones tratadas en Sesiones Async SQLAlchemy en FastAPI, bien hechas, y core/dependencies.py es también donde pondrás la dependencia de autenticación compartida en lugar de duplicarla por router.

Código compartido: Core, Schemas y Dependencies

core merece una regla específica: es para código que no tiene opinión sobre ningún dominio concreto. Una factoría de sesiones de base de datos no sabe ni le importa si está sirviendo a orders o a users — pertenece a core. Una dependencia get_current_user se usa en todas partes pero se implementa una sola vez — pertenece a core. Contrástalo con algo como OrderStatus, un enum que no significa nada fuera del dominio orders — eso se queda en orders/models.py, no en core, aunque pueda sentirse “compartido” porque varias funciones lo referencian.

Un anti-patrón común es un schemas.py que crece hasta convertirse en un cajón de sastre para todos los modelos de Pydantic de la app, incluidos los que en realidad son específicos de un dominio. Si un schema solo lo usan el router y el service de un dominio, pertenece dentro de la carpeta de ese dominio — moverlo a un paquete global schemas/ no lo hace más reutilizable, solo hace más difícil encontrarlo.

# app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from app.core.database import get_db
from app.core.security import decode_token

async def get_current_user(token: str = Depends(decode_token), db=Depends(get_db)):
    user = await db.get_user_by_token(token)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
    return user

Cada router de dominio importa get_current_user desde core, nunca desde dependencies.py de otro dominio — ese es el flujo en una sola dirección que mantiene el grafo de imports como un árbol en lugar de una telaraña.

Migrar una app plana existente

Reescribir un main.py plano que funciona hacia una estructura completa guiada por dominio en un solo pull request es una buena forma de introducir regresiones y dejar la migración a medias. Un camino más seguro:

  1. Crea primero app/core/ y mueve ahí la sesión de base de datos, la configuración y la dependencia de autenticación compartida — todavía no cambia nada más, y la app sigue funcionando.
  2. Elige el dominio más cargado (normalmente el que tiene más rutas o más bugs recientes) y extráelo a su propia carpeta — router, schemas, models, service — actualizando los imports sobre la marcha.
  3. Actualiza main.py para hacer include_router del dominio recién extraído junto al resto que aún sigue plano.
  4. Repite por dominio, un PR a la vez, para que cada cambio sea revisable y la app pueda desplegarse después de cada paso.
  5. Borra el archivo plano al final, cuando cada ruta se haya movido ya a una carpeta de dominio y main.py solo conecte las piezas.

Esto refleja cómo lo hacen realmente la mayoría de los equipos — de forma incremental, detrás de tests que funcionan, en lugar de una reescritura total.

Errores comunes

Error: routers importando desde otros routers. Casi siempre significa que una dependencia compartida se duplicó en lugar de promoverse a core. Solución: si dos routers necesitan lo mismo, pertenece a core, no a ninguno de los dos routers.

Error: lógica de negocio viviendo dentro de los handlers de ruta. Un handler que consulta la base de datos, aplica reglas de negocio y da formato a una respuesta hace que la lógica sea imposible de testear sin levantar toda la capa HTTP. Solución: mantén los handlers delgados — parsea la entrada, llama a una función de servicio y devuelve el resultado — y pon la lógica en service.py, donde pueda probarse directamente con tests unitarios.

Error: un único schemas.py gigante para toda la app. Deja de estar claro qué schemas se usan realmente y dónde, y las refactorizaciones tocan un archivo que todos los demás también están editando. Solución: delimita los schemas al dominio que les pertenece.

Error: mezclar estructura en capas y guiada por dominio de forma inconsistente. La mitad de la app organizada por dominio, la otra mitad por capa técnica, sin una regla clara para el código nuevo. Solución: elige un patrón de forma deliberada y documéntalo, aunque sea con una sola frase en el README.

Error: sobreingenierizar un prototipo de tres endpoints con cinco capas. Interfaces de repositorio, clases abstractas de servicio y contenedores de inyección de dependencias para una app con tres rutas añaden coste sin ningún beneficio correspondiente todavía. Solución: haz que la estructura coincida con el tamaño real — empieza en capas o incluso plano, y pasa a guiado por dominio cuando aparezca el dolor, no antes.

Buenas prácticas de producción

  • Haz cumplir la regla de imports en una sola dirección. router → service → models/schemas → core, nunca al revés. Si quieres automatizarlo, import-linter puede hacer cumplir límites de módulo en CI.
  • Versiona la API en la capa de composición de routers, no por endpoint — un árbol v2 de APIRouter montado junto a v1 es mucho menos propenso a errores que esparcir comprobaciones de versión por los handlers.
  • Haz que main.py sea aburrido. Debería crear la app, montar routers y registrar exception handlers y eventos de lifespan — nada más.
  • Escribe una carpeta tests/<domain>/ por dominio, reflejando la estructura de la app, para que sea obvio dónde pertenece un test nuevo.
  • Reevalúa la estructura en puntos de inflexión reales — un nuevo dominio, un segundo equipo, una extracción de servicio — en lugar de hacerlo según un calendario fijo.

A dónde ir desde aquí

La estructura del proyecto es la base sobre la que se apoya el resto del comportamiento de una aplicación FastAPI, pero no lo resuelve todo por sí sola. Una vez que las carpetas están en su sitio, los siguientes problemas suelen ser de tiempo de ejecución — y este grupo cubre los cinco que aparecen con más frecuencia:

Cierre

No existe una única estructura de carpetas correcta para FastAPI — existe una estructura que encaja con el tamaño de tu equipo y la forma de tu dominio, aplicada de forma consistente, con una regla clara sobre en qué dirección fluyen los imports. Empieza simple, observa la fricción específica que señala que es hora de cambiar y, cuando cambies, mueve un dominio a la vez detrás de tests que funcionen en lugar de intentar una reescritura. El objetivo nunca fueron los nombres de las carpetas — era una base de código en la que añadir el endpoint número cuarenta sea exactamente tan fácil como añadir el cuarto.

¿Tu app FastAPI actual sigue siendo un solo archivo, o ya ha sobrepasado su estructura?

Más artículos