Practical FastAPI × Clean Architecture Guide: Growing a Maintainable API with Router Splitting, a Service Layer, and the Repository Pattern
Introducción: el objetivo de este artículo
Llegar al punto en el que puedes construir una API pequeña con FastAPI suele ser bastante rápido.
Pero a medida que crecen los usuarios y las funciones, es posible que empieces a encontrarte poco a poco con problemas como:
- No estás seguro de dónde debería vivir una pieza de lógica
- Un archivo no deja de hacerse cada vez más grande
- Cada cambio de especificación te obliga a tocar muchos lugares y se siente arriesgado
Este artículo organiza patrones de diseño al estilo de clean architecture—con estructuras de directorios concretas y ejemplos de código—para ayudarte a evolucionar una app de FastAPI hacia algo mantenible a medio y largo plazo.
¿A quién le beneficia leer esto? (personas concretas)
-
Desarrolladores en solitario / aprendices
Tu app pequeña en FastAPI ha crecido, y piensas “main.pyestá a reventar…” o “misroutersson un caos…”
→ Obtendrás una ruta clara para organizar el código usando capas, una capa de servicios y el patrón repositorio sin pasarte. -
Ingenieros backend en equipos pequeños
Un equipo de 3–5 personas está construyendo una Web API con FastAPI y, a medida que crecen las funciones, los estilos empiezan a divergir.
→ Te llevarás una base arquitectónica realista—patrones compartidos sin reglas pesadas. -
Equipos startup construyendo un SaaS
Quieres pasar de “funciona” a “no nos dan miedo los cambios de especificación”.
→ Aprenderás a organizar responsabilidades por dominio y a migrar hacia una estructura testeable y descompuesta.
Notas de accesibilidad (consideraciones de legibilidad)
- Cada sección usa encabezados y se mantiene en ~1–3 párrafos, insertando código solo donde es necesario.
- Términos como “clean architecture”, “arquitectura por capas”, “repositorio” y “caso de uso” se explican en lenguaje sencillo la primera vez que aparecen.
- El código se muestra en bloques monoespaciados, con comentarios mínimos para facilitar el escaneo.
- Se asume que has usado Python y FastAPI en cierta medida, pero cada sección está pensada para leerse de forma independiente.
En conjunto, como artículo técnico, busca legibilidad y claridad aproximadamente al nivel WCAG AA.
1. Por qué importa la arquitectura: el límite de los “routers gordos”
Aclaremos brevemente por qué nos molestamos en usar capas.
Al principio, un código como este funciona perfectamente:
# El patrón de “todo en app/main.py” (un comienzo común)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import create_engine, text
app = FastAPI()
engine = create_engine("sqlite:///./app.db", echo=True)
class Article(BaseModel):
id: int | None = None
title: str
body: str
@app.post("/articles", response_model=Article)
def create_article(article: Article):
with engine.begin() as conn:
res = conn.execute(
text("INSERT INTO articles (title, body) VALUES (:t, :b) RETURNING id"),
{"t": article.title, "b": article.body},
)
article.id = res.scalar_one()
return article
Pero con el tiempo, este endpoint empieza a mezclar:
- Autenticación / autorización (roles, permisos)
- Reglas de validación con ramificaciones
- Reglas de negocio (transiciones de estado borrador → publicado)
- Acceso a BD y llamadas a APIs externas
- Logging, control transaccional, trazas, etc.
Sin darte cuenta, un endpoint se va a ~100 líneas y estás pensando “¿dónde arreglo esto siquiera?”
El propósito de la arquitectura—muy a grandes rasgos—son solo estas tres cosas:
- Reducir el radio de impacto de los cambios
- Separar el código por responsabilidad para que tu cerebro pueda cambiar de contexto
- Facilitar las pruebas (especialmente de la lógica de negocio)
2. El modelo básico de capas: ¿en qué “capas” lo separamos?
Clean architecture / arquitectura por capas tiene muchas variantes, pero un modelo mental realista y amigable con FastAPI es:
- Capa de presentación (routers de la API)
- Capa de aplicación (servicios / casos de uso)
- Capa de dominio (modelos de dominio / reglas de negocio)
- Capa de infraestructura (BD, APIs externas, mensajería)
Una estructura de directorios de ejemplo:
app/
api/
v1/
routers/
articles.py # Router (definición de endpoints)
core/
settings.py # Settings / infraestructura compartida
logging.py
domain/
articles/
models.py # Modelo de dominio
services.py # Casos de uso (capa de servicios)
repositories.py # Interfaz/contrato de repositorio
schemas.py # Esquemas Pydantic (API)
infra/
db/
base.py # Base, SessionLocal
articles/
sqlalchemy_repo.py # Implementación SQLAlchemy del repositorio
main.py
Las ideas clave:
- Los routers contienen solo lógica “de cara a HTTP” (extracción de info de auth, traducción request/response)
- La lógica de negocio vive en la capa de servicios
- El acceso a BD se aísla en repositorios
No necesitas separar perfecto desde el día uno—migra de forma gradual.
3. Reducir la responsabilidad del router: apunta a endpoints delgados
Desde la perspectiva del router, idealmente un endpoint se mantiene dentro de este flujo:
- Recibir parámetros/cuerpo de la request
- Extraer info del usuario/tenant desde auth
- Llamar a un método de la capa de servicios
- Devolver el resultado en un esquema de respuesta
Aquí tienes un ejemplo de “crear artículo” más limpio:
# app/api/v1/routers/articles.py
from fastapi import APIRouter, Depends, status
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.services import ArticleService
from app.deps.services import get_article_service
from app.deps.auth import get_current_user
router = APIRouter(prefix="/articles", tags=["articles"])
@router.post(
"",
response_model=ArticleRead,
status_code=status.HTTP_201_CREATED,
)
def create_article(
payload: ArticleCreate,
service: ArticleService = Depends(get_article_service),
current_user=Depends(get_current_user),
):
# Mantén aquí solo lógica adyacente a HTTP
article = service.create_article(
author_id=current_user.id,
data=payload,
)
return article
Aquí:
ArticleCreate/ArticleRead: esquemas Pydantic de cara a la APIArticleService: capa de servicios (lógica de negocio)get_current_user: dependencia que devuelve el usuario autenticado
El router se convierte en una capa de “recibir y pasar”.
4. Definir la capa de servicios (casos de uso)
Ahora la capa de servicios que contiene la lógica de negocio.
Usaremos un ArticleService que agrupa crear/actualizar/publicar, etc.
# app/domain/articles/services.py
from dataclasses import dataclass
from typing import Protocol
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.models import Article
class ArticleRepository(Protocol):
def add(self, article: Article) -> Article: ...
def get(self, article_id: int) -> Article | None: ...
# Añade find_by_author, etc. según haga falta
@dataclass
class ArticleService:
repo: ArticleRepository
def create_article(self, author_id: int, data: ArticleCreate) -> ArticleRead:
article = Article(
title=data.title,
body=data.body,
author_id=author_id,
status="draft", # El estado inicial es borrador
)
saved = self.repo.add(article)
return ArticleRead.model_validate(saved)
Esto hace:
- Crear el modelo de dominio (
Article) - Aplicar reglas de negocio (aquí:
status="draft") - Pedir al repositorio que lo persista
- Convertirlo a un esquema de respuesta
Por qué las capas de servicios son útiles:
- No están atadas a HTTP (se pueden reutilizar fuera de FastAPI)
- Son fáciles de testear (solo “mockeas” el repositorio)
- Los cambios de especificación tienen un “hogar” claro (lees la capa de servicios para ver las reglas)
5. Separar modelos de dominio y esquemas Pydantic
Tener un “modelo de dominio” en el centro hace que las reglas de negocio sean más fáciles de razonar.
Por simplicidad, este ejemplo trata el modelo SQLAlchemy como modelo de dominio.
# app/domain/articles/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, Text, ForeignKey
class Base(DeclarativeBase):
pass
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="draft", nullable=False)
Mantén separados los esquemas Pydantic orientados a la API:
# app/domain/articles/schemas.py
from pydantic import BaseModel
class ArticleCreate(BaseModel):
title: str
body: str
class ArticleRead(BaseModel):
id: int
title: str
body: str
author_id: int
status: str
class Config:
from_attributes = True
Esta separación ayuda a desacoplar:
- Tipos del “mundo HTTP” externo (
ArticleCreate,ArticleRead) - Tipos del “mundo dominio/BD” interno (
Article)
Así puedes evolucionar la implementación sin reescribir continuamente el contrato de la API.
6. Aislar el acceso a BD con el patrón repositorio
Ahora la capa de repositorios en infraestructura.
Desde la perspectiva del servicio, solo necesita “algo que guarde artículos”, por eso definimos primero una interfaz (Protocol).
# app/domain/articles/repositories.py
from typing import Protocol
from app.domain.articles.models import Article
class ArticleRepository(Protocol):
def add(self, article: Article) -> Article: ...
def get(self, article_id: int) -> Article | None: ...
Luego la implementación real con SQLAlchemy vive en infra:
# app/infra/articles/sqlalchemy_repo.py
from sqlalchemy.orm import Session
from app.domain.articles.models import Article
from app.domain.articles.repositories import ArticleRepository
class SqlAlchemyArticleRepository(ArticleRepository):
def __init__(self, db: Session):
self.db = db
def add(self, article: Article) -> Article:
self.db.add(article)
self.db.flush() # Asigna un id
self.db.refresh(article)
return article
def get(self, article_id: int) -> Article | None:
return self.db.get(Article, article_id)
Beneficios:
- La capa de servicios depende solo de la abstracción (
ArticleRepository) - Cambiar de base de datos después suele ser, en gran medida, “cambiar la implementación en infra”
- También puedes implementar fácilmente una versión fake/en memoria
7. Conectar las capas con la inyección de dependencias de FastAPI
Tras separar router/servicio/repositorio, la pregunta es “¿cómo los cableamos?”
Ahí brilla Depends de FastAPI.
7.1 Dependencia de sesión de BD
# app/infra/db/base.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///./app.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
7.2 Construir dependencias de repositorio + servicio
# app/deps/services.py
from fastapi import Depends
from sqlalchemy.orm import Session
from app.infra.db.base import get_db
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
from app.domain.articles.services import ArticleService
def get_article_repository(db: Session = Depends(get_db)):
return SqlAlchemyArticleRepository(db)
def get_article_service(repo=Depends(get_article_repository)) -> ArticleService:
return ArticleService(repo=repo)
Ahora el router solo hace Depends(get_article_service).
# app/api/v1/routers/articles.py (recap)
@router.post("", response_model=ArticleRead)
def create_article(
payload: ArticleCreate,
service: ArticleService = Depends(get_article_service),
current_user=Depends(get_current_user),
):
return service.create_article(author_id=current_user.id, data=payload)
Esto mantiene las capas separadas mientras usas el pipeline de inyección de FastAPI como pegamento.
8. Transacciones y el patrón Unit of Work (brevemente)
Cuando la lógica de negocio abarca múltiples repositorios, aparece:
“¿Dónde empezamos/confirmamos la transacción?”
Un enfoque común:
- Tratar cada método de servicio (caso de uso) como una unidad transaccional
- Usar un objeto Unit of Work para agrupar: begin → múltiples operaciones de repo → commit/rollback
Ejemplo simplificado:
# app/infra/db/unit_of_work.py
from contextlib import AbstractContextManager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.infra.db.base import SessionLocal
@dataclass
class UnitOfWork(AbstractContextManager):
session: Session | None = None
def __enter__(self):
self.session = SessionLocal()
return self
def __exit__(self, exc_type, exc, tb):
try:
if exc_type is None:
self.session.commit()
else:
self.session.rollback()
finally:
self.session.close()
En la capa de servicios puede verse así:
from app.infra.db.unit_of_work import UnitOfWork
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
def create_article_with_uow(author_id: int, data: ArticleCreate) -> ArticleRead:
with UnitOfWork() as uow:
repo = SqlAlchemyArticleRepository(uow.session)
article = Article(
title=data.title,
body=data.body,
author_id=author_id,
status="draft",
)
saved = repo.add(article)
return ArticleRead.model_validate(saved)
En proyectos reales, a menudo inyectas el UoW y te aseguras de que múltiples repositorios compartan la misma sesión.
9. Hacer que las pruebas sean más fáciles: prueba solo la capa de servicios
El valor de las capas se ve más claramente en los tests.
9.1 Test unitario de la capa de servicios
Como ArticleService depende solo de una interfaz de repositorio, podemos pasar un fake en memoria para pruebas:
# tests/test_article_service.py
from app.domain.articles.services import ArticleService
from app.domain.articles.schemas import ArticleCreate
from app.domain.articles.models import Article
class InMemoryArticleRepo:
def __init__(self):
self.items: list[Article] = []
self._next_id = 1
def add(self, article: Article) -> Article:
article.id = self._next_id
self._next_id += 1
self.items.append(article)
return article
def get(self, article_id: int) -> Article | None:
for a in self.items:
if a.id == article_id:
return a
return None
def test_create_article_sets_draft_status():
repo = InMemoryArticleRepo()
service = ArticleService(repo=repo)
data = ArticleCreate(title="Hello", body="World")
result = service.create_article(author_id=1, data=data)
assert result.status == "draft"
assert result.title == "Hello"
assert result.author_id == 1
Sin HTTP, sin BD—solo reglas de negocio.
Estos tests se convierten en una excelente protección contra regresiones cuando cambian las specs.
10. Separar por funcionalidad (dominio) para no perderte al escalar
Las estructuras de directorios varían, pero en FastAPI, “agrupar por funcionalidad/dominio” suele funcionar bien.
Por ejemplo:
domain/
articles/
models.py
services.py
repositories.py
schemas.py
users/
models.py
services.py
...
Beneficios:
- Los archivos relacionados quedan cerca, mejorando la legibilidad
- Es más fácil repartir responsabilidades en equipo (“owner” de artículos, “owner” de usuarios, etc.)
- Mantiene abierta la opción de extraer una funcionalidad a un servicio separado más adelante
Si separas carpetas estrictamente por capas (controller/service/repository), a menudo terminas saltando entre carpetas para entender una sola funcionalidad.
No hay una respuesta absoluta, pero “por funcionalidad” suele encajar bien en APIs web.
11. Antipatrones comunes y salidas suaves
La realidad incluye muchos dolores “clásicos”:
| Patrón | Por qué duele | Cómo escapar |
|---|---|---|
Todo en main.py |
Un archivo gigante es difícil de leer | Primero, separa routers en api/routers |
| Los routers se vuelven demasiado gordos | Se mezclan HTTP y lógica de negocio | Crea un módulo de servicios y mueve la lógica gradualmente |
| Acceso a BD escrito por todas partes | Conexiones/transacciones inconsistentes | Introduce repositorios y enruta el acceso a BD a través de ellos (empieza por un dominio) |
| Los tests son tan difíciles que se posponen | Cada cambio requiere verificación manual | Extrae la capa de servicios y añade primero tests unitarios |
La clave es: no intentes “limpiarlo todo” de golpe.
Refactoriza un endpoint a servicio + repositorio y repite. Los pasos pequeños son más amables contigo y con la base de código.
12. Qué cambia esta arquitectura (según la persona lectora)
-
Devs en solitario / aprendices
- Evitas el resultado de “deuda acumulada hasta que no se puede cambiar” a medida que crecen las funciones.
- También puedes hablar de forma concreta en entrevistas/proyectos: “puedo estructurar una app FastAPI así”, lo cual suma como portafolio.
-
Equipos pequeños
- Un entendimiento compartido de “dónde va cada cosa” hace reviews y colaboración mucho más fáciles.
- El onboarding se simplifica: “mira
domain/xxxy entenderás la mayor parte”.
-
Equipos SaaS
- Mantener las reglas de negocio en la capa de servicios da confianza: “todas las reglas viven aquí”.
- Puedes adaptarte con más facilidad a cambios arquitectónicos (p. ej., extraer una funcionalidad a un servicio separado).
13. Hoja de ruta de adopción: cómo mejorar de forma gradual
Un camino por etapas hacia una estructura tipo clean architecture en FastAPI:
-
Separar routers
- Mueve las definiciones de rutas fuera de
main.pyaapi/v1/routers/*.py. - Sin cambios de comportamiento—solo mejor visibilidad.
- Mueve las definiciones de rutas fuera de
-
Crear un módulo de servicios
- Elige un dominio representativo (p. ej., artículos), crea
domain/articles/services.pyy mueve la lógica de un endpoint.
- Elige un dominio representativo (p. ej., artículos), crea
-
Separar esquemas Pydantic de modelos de dominio
- Pon
ArticleCreate/ArticleReadenschemas.pyy haz que los routers se centren casi solo en esquemas.
- Pon
-
Introducir el patrón repositorio
- Centraliza acceso a BD en algo como
SqlAlchemyArticleRepository, y haz que el servicio dependa solo de la abstracción del repositorio.
- Centraliza acceso a BD en algo como
-
Empezar a escribir tests
- Comienza con tests de la capa de servicios usando repositorios “mock”, y amplía cobertura por caso de uso.
-
Expandir a otros dominios
- Aplica el mismo patrón a usuarios/comentarios/tags, etc.
- Añade UoW, eventos y otros patrones avanzados solo cuando hagan falta.
Intentar hacerlo todo de una vez abruma—hazlo una funcionalidad, un endpoint cada vez.
Enlaces de referencia (para profundizar)
Son recursos generales sobre clean architecture y diseño con FastAPI. Revisa cada fuente para tener la información más reciente.
-
Clean architecture / diseño en general
- Clean Architecture: A Craftsman’s Guide to Software Structure and Design
- Domain-Driven Design (libros/guías introductorias)
-
Python / FastAPI + clean architecture
-
Patrón repositorio / casos de uso
- Eric Evans, Domain-Driven Design
- Martin Fowler, Patterns of Enterprise Application Architecture
-
Testing y arquitectura
- FastAPI Testing
- Documentación oficial de pytest
Reflexiones finales
FastAPI hace maravillosamente fácil construir “algo que funciona”.
Al mismo tiempo, main.py y los routers pueden crecer silenciosamente hasta que tocarlos da miedo.
Los patrones cubiertos aquí—capas, capa de servicios y patrón repositorio—son simplemente una “forma” viable para:
- Encontrar más fácilmente la lógica de negocio
- Reducir el radio de impacto de los cambios
- Facilitar las pruebas
No hay una única respuesta absolutamente correcta. Pero si empiezas con esta forma y la adaptas gradualmente a tu equipo y proyecto, tu app de FastAPI tiene muchas más probabilidades de convertirse en algo con lo que puedas vivir a largo plazo.
A tu ritmo, prueba a empezar con un solo endpoint.
Poco a poco, terminarás con una API más limpia, más segura de cambiar y más agradable de construir.

