Cuando un proyecto crece más allá de dos archivos, la estructura deja de ser un detalle estético y se convierte en arquitectura. La mayoría de los tutoriales te enseñan Python en un solo main.py; eso está bien para aprender, pero en producción necesitas algo que permita a cinco personas trabajar en paralelo, ejecutar tests sin levantar un servidor real, y desplegar con confianza.
La arquitectura por capas (a veces llamada clean architecture o hexagonal) es el patrón que resuelve esto. La idea central: el código que expresa qué hace tu negocio no debe saber nada sobre cómo lo almacenas ni cómo lo expones. Tu entidad Order no debería importar sqlalchemy ni fastapi. Si lo hace, no puedes testearla sin una base de datos corriendo, y eso es pain puro.
El diseño funciona porque las dependencias solo fluyen hacia adentro: la infraestructura depende de la aplicación, que depende del dominio. Nunca al revés. El dominio es puro Python; no tiene idea de que existe PostgreSQL.
¿Cuándo aplica esto? Desde el momento en que tienes lógica de negocio real: cálculos, reglas de validación, procesos que tienen nombre en el problema de negocio. Si solo estás pegando una API a una tabla, quizás sobra. Pero si ese código va a crecer, la separación te va a salvar semanas de refactoring.
Lo que se rompe si lo ignoras: acabas con un views.py de 800 líneas que mezcla validación HTTP, reglas de negocio y queries SQL. Testear una sola regla requiere mockear media aplicación. Cambiar de ORM se vuelve cirugía mayor.
Estructura de directorios
my-project/ ├── src/ │ └── my_package/ │ ├── __init__.py │ ├── domain/ # Lógica de negocio pura │ │ ├── __init__.py │ │ ├── entities.py │ │ └── exceptions.py │ ├── application/ # Casos de uso: orquesta el dominio │ │ ├── __init__.py │ │ └── use_cases.py │ └── infrastructure/ # DB, HTTP, email, etc. │ ├── __init__.py │ ├── repositories.py │ └── web.py ├── tests/ │ ├── unit/ │ │ └── test_entities.py │ └── integration/ │ └── test_repositories.py ├── docs/ ├── pyproject.toml ├── Makefile └── .pre-commit-config.yaml
El src/ layout (en lugar de poner el paquete en la raíz) es deliberado: evita que Python importe tu código fuente directamente desde el directorio de trabajo sin instalarlo, lo que obliga a que pyproject.toml esté bien configurado y los imports sean honestos.
Ejemplo completo
Un sistema de pedidos simplificado que muestra las tres capas en acción.
# src/my_package/domain/entities.py
# El dominio no importa nada externo. Solo stdlib si acaso.
from dataclasses import dataclass, field
from decimal import Decimal
from uuid import UUID, uuid4
@dataclass
class OrderItem:
product_id: UUID
quantity: int
unit_price: Decimal
def subtotal(self) -> Decimal:
return self.unit_price * self.quantity
@dataclass
class Order:
id: UUID = field(default_factory=uuid4)
customer_id: UUID = None
items: list[OrderItem] = field(default_factory=list)
confirmed: bool = False
def add_item(self, item: OrderItem) -> None:
if self.confirmed:
# La regla de negocio vive aquí, no en la capa web
raise ValueError("Cannot modify a confirmed order")
self.items.append(item)
def confirm(self) -> None:
if not self.items:
raise ValueError("Cannot confirm an empty order")
self.confirmed = True
def total(self) -> Decimal:
return sum(item.subtotal() for item in self.items)
# src/my_package/domain/exceptions.py
class OrderNotFound(Exception):
"""Se lanza cuando un pedido no existe en el repositorio."""
# src/my_package/application/use_cases.py
# La capa de aplicación conoce el dominio y los protocolos de repositorio,
# pero no sabe si el repositorio usa Postgres, SQLite o memoria.
from decimal import Decimal
from typing import Protocol
from uuid import UUID
from my_package.domain.entities import Order, OrderItem
from my_package.domain.exceptions import OrderNotFound
# Protocol define el contrato sin acoplarse a ninguna implementación
class OrderRepository(Protocol):
def get(self, order_id: UUID) -> Order: ...
def save(self, order: Order) -> None: ...
class AddItemToOrder:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
def execute(
self,
order_id: UUID,
product_id: UUID,
quantity: int,
unit_price: Decimal,
) -> Order:
order = self._repo.get(order_id) # lanza OrderNotFound si no existe
item = OrderItem(
product_id=product_id,
quantity=quantity,
unit_price=unit_price,
)
order.add_item(item)
self._repo.save(order)
return order
# src/my_package/infrastructure/repositories.py
# Aquí vive SQLAlchemy, Redis, o lo que sea.
# El dominio nunca supo que este archivo existía.
from uuid import UUID
from my_package.domain.entities import Order
from my_package.domain.exceptions import OrderNotFound
class InMemoryOrderRepository:
"""Repositorio en memoria; perfecto para tests de integración rápidos."""
def __init__(self) -> None:
self._store: dict[UUID, Order] = {}
def get(self, order_id: UUID) -> Order:
try:
return self._store[order_id]
except KeyError:
raise OrderNotFound(order_id)
def save(self, order: Order) -> None:
self._store[order.id] = order
# tests/unit/test_entities.py
# Este test no necesita base de datos, servidor, ni mocks de frameworks.
import pytest
from decimal import Decimal
from uuid import uuid4
from my_package.domain.entities import Order, OrderItem
def make_item(price: str = "10.00", qty: int = 2) -> OrderItem:
return OrderItem(
product_id=uuid4(),
quantity=qty,
unit_price=Decimal(price),
)
def test_order_total_sums_all_items():
order = Order(customer_id=uuid4())
order.add_item(make_item("10.00", 2)) # 20
order.add_item(make_item("5.00", 3)) # 15
assert order.total() == Decimal("35.00")
def test_cannot_add_item_to_confirmed_order():
order = Order(customer_id=uuid4())
order.add_item(make_item())
order.confirm()
with pytest.raises(ValueError, match="confirmed"):
order.add_item(make_item())
# pyproject.toml — configuración centralizada del proyecto [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "my-package" version = "0.1.0" requires-python = ">=3.11" dependencies = [] [project.optional-dependencies] dev = ["pytest", "ruff", "mypy", "pre-commit"] [tool.hatch.build.targets.wheel] packages = ["src/my_package"] [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] src = ["src"] line-length = 88
# Makefile — interfaz humana para los comandos del proyecto .PHONY: install test lint typecheck install: pip install -e ".[dev]" test: pytest tests/unit -v test-all: pytest -v lint: ruff check src tests typecheck: mypy src
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: []
Lo que hace funcionar esta estructura
El Protocol en use_cases.py es la pieza clave de todo el diseño. Define qué métodos necesita el caso de uso sin importar ninguna clase concreta. InMemoryOrderRepository cumple el contrato solo por tener los mismos métodos con las mismas firmas — duck typing estructural. Cuando en producción uses SqlAlchemyOrderRepository, el caso de uso ni se entera del cambio.
La regla ValueError en Order.add_item pertenece al dominio, no al router de FastAPI. Si esa lógica estuviera en web.py, no podrías testearla sin levantar HTTP. Al estar en la entidad, test_cannot_add_item_to_confirmed_order corre en microsegundos.
InMemoryOrderRepository en tests es un patrón que vale oro: implementas el mismo Protocol con un diccionario en memoria, y tus tests de integración a nivel de caso de uso son rápidos y deterministas. La implementación real de SQLAlchemy se prueba por separado, en tests/integration/, donde sí quieres una base de datos real (o una de test).
El src/ layout evita el problema clásico donde import my_package resuelve al directorio local en vez del paquete instalado. Con el layout src/, si no has hecho pip install -e ., Python no encuentra el paquete y falla ruidosamente — que es exactamente lo que quieres: errores claros, no comportamiento sorpresa.
pyproject.toml unifica todo lo que antes se repartía entre setup.py, setup.cfg, tox.ini y pytest.ini. Ruff, mypy, pytest y el empaquetado viven en el mismo archivo.
Errores que debes conocer
Error: importar infraestructura desde el dominio rompe toda la separación de capas y hace que los tests unitarios necesiten dependencias externas.
# ❌ Wrong
# src/my_package/domain/entities.py
from sqlalchemy import Column, String # el dominio ahora conoce SQLAlchemy
class Order(Base):
__tablename__ = "orders"
id = Column(String, primary_key=True)
# ✅ Right
# src/my_package/domain/entities.py
from dataclasses import dataclass
from uuid import UUID
@dataclass
class Order:
id: UUID
# SQLAlchemy no existe aquí
El modelo de dominio usa un dataclass puro; la persistencia se maneja en un mapper de infraestructura separado.
Error: definir el Protocol del repositorio en la capa de infraestructura crea una dependencia circular — la aplicación importaría de infraestructura.
# ❌ Wrong
# src/my_package/infrastructure/repositories.py
class OrderRepository(Protocol): # el contrato no pertenece aquí
def get(self, order_id: UUID) -> Order: ...
# ✅ Right
# src/my_package/application/use_cases.py
class OrderRepository(Protocol): # el contrato lo define quien lo necesita
def get(self, order_id: UUID) -> Order: ...
def save(self, order: Order) -> None: ...
El Protocol vive en la capa de aplicación porque es la capa que define qué necesita, no la que lo implementa — así las dependencias siguen fluyendo hacia adentro.
N° 203