Icono del sitio IT&ライフハックブログ|学びと実践のためのアイデア集

Practical FastAPI × Clean Architecture Guide: Growing a Maintainable API with Router Splitting, a Service Layer, and the Repository Pattern

green snake

Photo by Pixabay on Pexels.com

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.py está a reventar…” o “mis routers son 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:

  1. Capa de presentación (routers de la API)
  2. Capa de aplicación (servicios / casos de uso)
  3. Capa de dominio (modelos de dominio / reglas de negocio)
  4. 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:

  1. Recibir parámetros/cuerpo de la request
  2. Extraer info del usuario/tenant desde auth
  3. Llamar a un método de la capa de servicios
  4. 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 API
  • ArticleService: 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/xxx y 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:

  1. Separar routers

    • Mueve las definiciones de rutas fuera de main.py a api/v1/routers/*.py.
    • Sin cambios de comportamiento—solo mejor visibilidad.
  2. Crear un módulo de servicios

    • Elige un dominio representativo (p. ej., artículos), crea domain/articles/services.py y mueve la lógica de un endpoint.
  3. Separar esquemas Pydantic de modelos de dominio

    • Pon ArticleCreate / ArticleRead en schemas.py y haz que los routers se centren casi solo en esquemas.
  4. 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.
  5. Empezar a escribir tests

    • Comienza con tests de la capa de servicios usando repositorios “mock”, y amplía cobertura por caso de uso.
  6. 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.


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.

Salir de la versión móvil