El Modelo Mental de Arquitectura para Servicios Python
Arquitectura, una vez que dejas de lado la elección del framework y los debates sobre nombres de carpetas, son simplemente el conjunto de decisiones en una base de código Python que son costosas de revertir: qué módulo puede importar a qué otro módulo, dónde se encuentra el límite de un Protocol, si la lógica de dominio sabe que existe un modelo ORM.
Las otras páginas de esta sección cubren cada pieza de eso en profundidad: Estructura de src y Organización de Paquetes, Arquitectura Limpia / Hexagonal, Modelado de Dominio, Inyección de Dependencias y Conexión, Configuración y Ajustes.
Esta página es la capa debajo de todas ellas: lo que realmente hace que una base de código Python sea fácil o difícil de cambiar, expresado en términos que Python mismo hace literales —el grafo de importación— en lugar de en diagramas abstractos tomados prestados de la cultura de arquitectura de otro lenguaje.
Resumen
- En Python, el grafo de importación es la arquitectura: cada
importes una flecha de dependencia, y la dirección en que apuntan esas flechas determina qué puede cambiar independientemente de qué. - Por Qué Importa: El flexible sistema de importación de Python hace que los límites débiles sean invisibles hasta que se manifiestan como importaciones circulares, módulos "dios" o "por qué este cambio de una línea toca doce archivos".
- Conceptos Clave: acoplamiento, cohesión, dirección de dependencia, inversión de dependencia, raíz de composición, grafo de importación.
- Cuándo Usar: Decidir dónde pertenece el límite de un paquete, si un puerto
Protocolvale la pena la indirección, cómo conectar adaptadores concretos a dependencias abstractas al inicio, o si el código de dominio debe importar alguna vez un tipo de framework u ORM. - Limitaciones / Compensaciones: Cada límite que reduce el acoplamiento —un
Protocol, un paquete adicional, una raíz de composición— añade un archivo que tienes que leer para entender qué sucede realmente, y el tipado dinámico de Python significa que estos límites son solo tan fuertes como tus herramientas y tu disciplina los apliquen. - Temas Relacionados: estructura de
src, arquitectura hexagonal, inyección de dependencias, modelado de dominio, importaciones circulares.
Fundamentos
Dos propiedades determinan casi todo sobre si una base de código Python se mantiene fácil de cambiar: acoplamiento —cuánto depende un módulo de los internos de otro módulo en lugar de su interfaz declarada— y cohesión —cuánto pertenecen realmente las cosas dentro de un módulo a la misma responsabilidad.
El objetivo es el mismo que persigue la arquitectura en cualquier lenguaje: alta cohesión dentro de un módulo, bajo acoplamiento entre módulos, pero Python hace que el lado del acoplamiento sea inusualmente visible, porque cada declaración de import es una flecha de dependencia literal e inspeccionable, no una relación implícita que tengas que inferir de un diagrama.
Una analogía útil: piensa en los paquetes como apartamentos en un edificio, cada uno con sus propias utilidades y exactamente una puerta principal: la API pública del módulo, lo que sea que exponga a través de __init__.py y __all__. Un paquete bien delimitado te permite renovar su interior sin que un vecino se dé cuenta, porque nada exterior llega a través de las paredes; uno mal delimitado tiene residentes que constantemente meten la mano en los apartamentos de los demás para buscar un fusible de repuesto, por lo que nadie puede cambiar nada sin revisar tres unidades más primero.
# domain/orders.py - buscando un fusible de repuesto a través de la pared
from adapters.sqlite_orders import SqliteOrderRepository # el dominio ahora sabe que sqlite existe
def place_order(order_id: str, total_cents: int) -> None:
repo = SqliteOrderRepository() # no se puede probar sin un archivo de base de datos real
repo.save(order_id, total_cents)Mecánica e Interacciones
La dirección de dependencia es donde esto se convierte en una regla concreta y verificable en lugar de una preferencia: si domain/orders.py importa adapters/sqlite_orders.py, la flecha apunta del dominio a la infraestructura, y cambiar SQLite por Postgres ahora significa tocar la lógica de negocio, no solo el adaptador.
Esta es exactamente la causa mecánica de uno de los errores estructurales más comunes de Python. Una importación circular —ImportError: cannot import name X from partially initialized module Y— no es realmente una peculiaridad del intérprete que debas evitar; es el grafo de importación que te dice, en tiempo de ejecución, que dos módulos creen que necesitan algo que el otro posee, lo cual es acoplamiento hecho visible como un fallo en lugar de permanecer oculto como una fricción a cámara lenta. Escenarios de Importación Circular cubre la mecánica de arreglar el síntoma; esta página trata sobre por qué existe el síntoma: dos módulos en el lado equivocado de una flecha de dependencia.
La inversión de dependencia es la técnica que arregla la dirección en lugar de solo el síntoma: en lugar de que el dominio importe un adaptador concreto, el dominio define un typing.Protocol que describe lo que necesita, y el adaptador implementa esa forma.
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order_id: str, total_cents: int) -> None: ...
def place_order(order_id: str, total_cents: int, repo: OrderRepository) -> None:
repo.save(order_id, total_cents) # el dominio nunca importa sqlite, fastapi, ni nada concretoEl tipado estructural de Python hace que esto sea más barato que en lenguajes que requieren una jerarquía de clases explícita: cualquier objeto con un método save coincidente satisface OrderRepository, por lo que una prueba puede pasarle a place_order un simulador en memoria sin herencia ni framework de mocking.
Alguien todavía tiene que conectar el adaptador concreto al puerto abstracto, y eso sucede en exactamente un lugar: la raíz de composición —un main(), un bloque lifespan de FastAPI, un hook app-ready de Django— donde se construyen objetos concretos y se entregan a las funciones y clases que solo conocen Protocols. En cualquier otro lugar de la base de código se deben recibir dependencias, no construirlas.
Consideraciones Avanzadas y Aplicaciones
Estructura de src y Organización de Paquetes es donde la arquitectura deja de ser una convención de revisión de código y se convierte en algo que CI realmente aplica: una estructura plana permite que la raíz del repositorio satisfaga las importaciones durante el desarrollo local incluso cuando la metada de empaquetado está rota, mientras que src/ fuerza una instalación (pip install -e . o uv sync) antes de que algo pueda importar el paquete en absoluto, de modo que una dependencia faltante o una entrada incorrecta en pyproject.toml falle en CI, no tres semanas después en una imagen de Docker.
La configuración merece el mismo tratamiento de inversión de dependencia que cualquier otra preocupación de infraestructura: el código que llama a os.environ.get(...) disperso por los módulos de dominio está tan acoplado a "el entorno" como el código que importa SQLite directamente está acoplado a una base de datos. Configuración y Ajustes cubre la construcción de un único objeto de configuración tipado en la raíz de composición y su inyección, en lugar de permitir que cada módulo acceda a su propia variable de entorno.
No todas las bases de código Python deberían pagar por una separación hexagonal completa, y eso es una compensación real, no un compromiso. El estilo de "modelo gordo" de Django —lógica de negocio que vive directamente en los modelos ORM— es una opción legítima y productiva para aplicaciones CRUD centradas en la administración donde el esquema de la base de datos y el modelo de dominio tienen genuinamente la misma forma; la separación hexagonal se gana su indirección específicamente cuando las reglas de dominio son lo suficientemente complejas, o se espera que sobrevivan al framework o base de datos actual el tiempo suficiente, como para que probarlas sin infraestructura sea valioso por sí mismo.
La progresión de un script plano, a una aplicación en capas por convención, a límites de módulo forzados, a puertos y adaptadores hexagonales completos, se mapea directamente al costo de cambio: cada paso reduce el costo de cambiar una parte de forma aislada y aumenta el costo fijo del propio mecanismo de límite, por lo que un equipo que paga por puertos Protocol en un script interno de 200 líneas está pagando sobrecarga pura, y un equipo sin ellos en un servicio con mucha lógica de dominio y tres mecanismos de entrega está pagando sobrecarga pura en la otra dirección.
| Enfoque | Fortaleza | Debilidad | Mejor Ajuste |
|---|---|---|---|
| Script plano / módulo único | Más rápido de escribir, cero indirección | El acoplamiento crece invisiblemente a medida que el archivo crece | Prototipos, scripts únicos, notebooks |
En capas por convención (carpetas domain/, api/, adapters/, sin aplicación) | Intención clara, herramientas aún sencillas | Nada impide que un módulo de dominio importe directamente un adaptador | Equipos pequeños, servicios en etapa temprana |
Estructura src + límites de importación forzados (reglas de ruff/import-linter) | Empaquetado incorrecto e importaciones entre capas fallan en CI, no en un portátil | Requiere paso de instalación y configuración del linter para aplicar realmente | Cualquier servicio o biblioteca instalable distribuido a través de CI/Docker |
Hexagonal (puertos Protocol + adaptadores) | Dominio testeable sin base de datos, núcleo independiente del framework | Una interfaz adicional para leer incluso en los casos simples | Reglas de dominio complejas, múltiples mecanismos de entrega (API + CLI + worker) |
Conceptos Erróneos Comunes
- "La estructura
srces solo una carpeta extra sin razón." Es un mecanismo de aplicación: fuerza una instalación editable antes de que algo pueda importar el paquete, lo cual es lo que expone metadatos de empaquetado rotos en CI en lugar de solo en una compilación de Docker semanas después. - "Los puertos basados en
Protocolson una sobreingeniería al estilo Java para un lenguaje dinámico." El tipado estructural de Python hace que unProtocolsea más barato que una interfaz en la mayoría de los lenguajes con tipado estático: sin herencia, sin jerarquía, solo una forma que un simulador o un adaptador real pueden satisfacer para las pruebas. - "Las importaciones circulares son una peculiaridad de Python que se arregla con una importación local dentro de la función." Una importación local puede silenciar el error, pero la causa subyacente —dos módulos que dependen cada uno de los internos del otro— es un problema de acoplamiento que la importación local oculta en lugar de resolver.
- "Los 'modelos gordos' de Django que centran en el ORM son siempre una mala arquitectura." Para aplicaciones administrativas centradas en CRUD donde el esquema y el modelo de dominio tienen la misma forma, los modelos gordos son una opción razonable y productiva; la separación hexagonal es una compensación que vale la pena específicamente cuando la complejidad del dominio o la rotación de infraestructura justifican la indirección adicional.
- "Necesitas un framework de inyección de dependencias para hacer DI en Python." La inyección de constructor más una raíz de composición (un
main(), un bloquelifespande FastAPI) cubren la mayoría de los servicios de Python; un contenedor de DI añade valor real principalmente una vez que los grafos de conexión se vuelven lo suficientemente grandes como para que la construcción manual se convierta en su propia carga de mantenimiento.
Preguntas Frecuentes
¿Qué significa concretamente "arquitectura" para una base de código Python?
El conjunto de decisiones de dirección de importación y límites que son costosas de revertir: qué módulo puede importar cuál, dónde se encuentra un puerto Protocol, si la lógica de dominio conoce un framework u ORM. Todo lo demás es código ordinario que es barato de cambiar.
¿Por qué el grafo de importación se describe como "la arquitectura" en lugar de solo un detalle de ella?
Porque en Python, cada declaración de import es una flecha de dependencia real e inspeccionable: no se necesita un diagrama de arquitectura separado para ver el acoplamiento, ya que python -c "import ast; ..." o cualquier herramienta de grafo de importación puede mostrarte la estructura de dependencia real directamente desde el código.
¿Es una importación circular siempre una señal de mala arquitectura?
Casi siempre, sí: significa que dos módulos creen que necesitan algo que el otro posee, lo cual es acoplamiento hecho visible como un error en tiempo de ejecución. Una importación local y diferida puede silenciar el error, pero el problema subyacente de dirección todavía está ahí hasta que un módulo deja de depender de los internos del otro.
¿Por qué usar typing.Protocol en lugar de una clase base abstracta para los puertos?
Protocol proporciona tipado estructural: cualquier objeto con métodos coincidentes lo satisface, sin necesidad de herencia, lo cual es más barato para las pruebas (un objeto simulado simple funciona) y no impone una jerarquía de clases a los adaptadores que ya pueden heredar de algo más, como una clase base de ORM.
¿Qué es una raíz de composición, concretamente?
El único lugar en un servicio —una función main(), un bloque lifespan de FastAPI, un hook app-ready de Django— donde se construyen objetos concretos (una conexión a base de datos, un repositorio real) y se entregan al código que solo conoce tipos Protocol abstractos. En cualquier otro lugar se deben recibir dependencias, no construirlas.
¿La estructura `src` realmente cambia cómo se ejecuta el código, o solo dónde viven los archivos?
Ambos, en efecto: la ubicación física cambia (src/mypackage/ en lugar de un paquete de nivel superior), pero el cambio real es de comportamiento: fuerza pip install -e . o uv sync antes de que las importaciones se resuelvan, lo cual es lo que detecta metadatos de empaquetado faltantes que una estructura plana ocultaría silenciosamente durante el desarrollo local.
¿Cuándo es un "modelo gordo" de Django una arquitectura razonable, no un atajo?
Cuando la aplicación está genuinamente centrada en CRUD y el esquema de la base de datos ya coincide estrechamente con el modelo de dominio: herramientas administrativas, paneles internos; poner reglas de negocio directamente en modelos ORM evita una indirección que no aportaría nada en ese contexto.
¿Cómo encaja la configuración en la inversión de dependencias?
De la misma manera que una base de datos o un cliente HTTP: el código que llama a os.environ.get(...) en los módulos de dominio está acoplado al "entorno" de la misma manera que el código que importa SQLite directamente está acoplado a una base de datos. Construir un único objeto de configuración tipado en la raíz de composición e inyectarlo mantiene esa dependencia explícita e intercambiable.
¿Siempre vale la pena la indirección adicional para agregar un puerto `Protocol`?
No: para un script pequeño o una herramienta desechable, una dependencia concreta directa es más simple y la interfaz "adicional" es sobrecarga pura. Se gana su costo específicamente cuando la lógica de dominio detrás de ella es lo suficientemente compleja, o se espera que sobreviva a la elección de infraestructura actual el tiempo suficiente, como para que probarla de forma aislada importe.
¿Cómo sé si los límites de mi paquete están forzados o son solo convencionales?
Pregunta si algo realmente detiene una violación: una regla de linter que bloquea importaciones profundas entre paquetes, una estructura src que requiere una instalación, una verificación del grafo de importación en CI, en lugar de solo un entendimiento compartido de "no hacemos eso aquí", que se erosiona bajo la presión de los plazos.
¿Necesito un framework/contenedor de DI en Python?
Generalmente no para tamaños de servicio típicos: la inyección de constructor manual más una raíz de composición maneja la mayoría de los grafos de conexión. Un contenedor comienza a valer la pena una vez que el número de dependencias y sus tiempos de vida (singleton vs por solicitud) se vuelven lo suficientemente grandes como para que la conexión manual sea difícil de leer.
¿Por qué la dirección de dependencia importa más a medida que un equipo crece?
Porque el límite forzado de un módulo suele ser también un límite de propiedad: cuando la API pública de un paquete es estrecha y forzada, un equipo puede cambiar sus internos sin coordinarse con todos los demás equipos que tocan la base de código, mientras que los límites débiles obligan a la coordinación incluso cuando las responsabilidades subyacentes no se superponen realmente.
Relacionados
- Estructura de
srcy Organización de Paquetes - el mecanismo físico de aplicación detrás de los límites de los paquetes - Arquitectura Limpia / Hexagonal - puertos y adaptadores de
Protocolen profundidad - Modelado de Dominio - qué pertenece dentro del límite que describe esta página
- Inyección de Dependencias y Conexión - el patrón de raíz de composición en la práctica
- Configuración y Ajustes - invertir el entorno como una dependencia
- Escenarios de Importación Circular - el síntoma en tiempo de ejecución de una mala dirección de dependencia
Versiones de Stack: Esta página es conceptual y no está vinculada a una versión específica de stack.