La regla de oro: las dependencias apuntan hacia adentro
La Clean Architecture suele representarse como cuatro círculos concéntricos, y ese dibujo hace que parezca más complicada de lo que realmente es. La idea central es una única restricción sobre las importaciones: las dependencias del código fuente apuntan hacia adentro, hacia el dominio. Las capas externas pueden importar capas internas. Las capas internas no pueden importar las externas.
Esa es la regla de dependencia, y casi todo lo demás es una consecuencia de ella. El dominio —el código que decide qué es un pedido y cuándo puede cancelarse— no importa nada del framework, del driver de la base de datos ni de la librería HTTP. Es código puro que podría ejecutarse en un proceso de prueba, un script o un runtime completamente diferente.
La razón para priorizar esto no es la pureza por sí misma. Es que las cosas con más probabilidades de cambiar están en el exterior. Las bases de datos se actualizan o se reemplazan, los frameworks HTTP pasan de moda, un proveedor de pagos se cambia por otro. Las reglas del negocio también cambian, pero mucho más lentamente y por razones distintas. Si las reglas dependen de las herramientas, cada cambio de herramienta arrastra las reglas consigo. Si la dependencia apunta hacia adentro, un cambio de herramienta queda confinado a un adapter.
Es el mismo instinto que separar una librería de quienes la llaman. Quieres que la parte valiosa y estable sea utilizable y testeable sin tener que cargar con las partes volátiles.
La regla trata sobre las importaciones, no sobre las carpetas. Renombrar services a domain y mover archivos de lugar no sirve de nada si el código interno sigue importando el ORM. La prueba es mecánica: abre el archivo más interno y mira su lista de importaciones. Si menciona una base de datos, un framework o un proveedor, el límite es meramente decorativo.
Las capas y qué tiene permitido saber cada una
El diagrama clásico tiene cuatro anillos. De adentro hacia afuera:
Entities (o el dominio). Los objetos de negocio y sus invariantes: un Order debe tener al menos una línea, el valor de un Money no puede mezclar monedas, una factura no puede pagarse dos veces. Esta capa es la más estable y la más valiosa. Debe ser código puro sin importaciones externas.
Use cases (la capa de aplicación). Las cosas que el sistema hace: realizar un pedido, cancelar una suscripción, restablecer una contraseña. Un use case orquestra las entities para cumplir una intención, llama a los ports que necesita y devuelve datos simples. Contiene el flujo, no las reglas.
Interface adapters. Controllers, presenters, implementaciones de repositorios, serializadores. Estos traducen los formatos del mundo exterior a los del dominio. Un controller convierte una solicitud HTTP en una entrada para un use case; un repositorio convierte objetos del dominio en filas y viceversa.
Frameworks and drivers. Express, Postgres, Redis, el SDK de la nube. El anillo más externo, donde reside la mayor cantidad de detalles y donde se requiere menos pensamiento de diseño. Esta capa es el pegamento.
La regla es simple: una flecha puede apuntar hacia adentro, pero nunca hacia afuera. El use case puede llamar a OrderRepository, porque esa interfaz está definida en el dominio. El dominio no puede llamar a pg.Pool, porque pg es un detalle externo. Cuando sientas la urgencia de importar un tipo de base de datos en un use case, la respuesta no es una mejor importación, sino un port.
Puertos y adaptadores, la visión hexagonal
La arquitectura hexagonal, también llamada puertos y adaptadores, plantea lo mismo mediante una imagen que a muchas personas les resulta más fácil de aplicar. La aplicación se encuentra en el centro. A su alrededor están los puertos: interfaces que declaran qué necesita la aplicación del exterior y qué puede pedirle el exterior a ella. Conectados a esos puertos están los adaptadores: implementaciones concretas.
Existen dos direcciones de puertos:
- Puertos impulsados (outbound) describen lo que la aplicación necesita: un
OrderRepository, unPaymentGateway, unClock. El dominio es el dueño de la interfaz; la infraestructura la implementa. - Puertos impulsores (inbound) describen lo que se le puede pedir a la aplicación: una interfaz de caso de uso que un controlador o un consumidor de mensajes llama.
La parte importante es la propiedad (ownership). La interfaz vive junto al código que la utiliza, en la capa interna, no junto a la implementación. Esto es lo que hace posible la inversión de dependencias: el dominio declara OrderRepository, y la clase de Postgres importa el dominio para implementarlo. La flecha de las importaciones apunta hacia adentro, aunque la flecha de control apunte hacia afuera.
Un puerto debe estar definido por las necesidades de la aplicación, no por las capacidades de la base de datos. Si OrderRepository expone findByCustomerAndStatusPaginated, el esquema de la base de datos se ha filtrado en el vocabulario de la aplicación. Si expone findByCustomer, la aplicación está describiendo lo que necesita y el adaptador puede decidir cómo satisfacerlo.
La raíz de composición (composition root) es lo que hace que el esquema funcione en tiempo de ejecución. Las interfaces por sí solas no conectan nada; alguien tiene que elegir la implementación y entregarla. Esa elección ocurre una sola vez, al iniciar la aplicación, en un único archivo. Si te encuentras construyendo un PostgresOrderRepository dentro de un caso de uso, la inversión es nominal: el caso de uso simplemente ha movido su dependencia de una importación a una llamada al constructor.
La inversión de dependencias en la práctica
La inversión de dependencias es el mecanismo, no el objetivo. El objetivo es que el dominio defina el contrato y la infraestructura lo cumpla.
Sin inversión, el caso de uso importa la clase del repositorio:
import { PostgresOrderRepository } from "../infrastructure/postgres-order-repository.js";
Con inversión, el caso de uso importa una interfaz, y la clase concreta se pasa como argumento:
import type { OrderRepository } from "../domain/order-repository.js";
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
}
El repositorio concreto se elige una sola vez, al iniciar la aplicación, en un composition root. Ese es el único archivo que importa tanto el dominio como la infraestructura. Todo lo demás interactúa a través de interfaces.
const orderRepository = new PostgresOrderRepository(pool);
const placeOrder = new PlaceOrder(orderRepository);
El beneficio práctico es inmediato. En producción, PlaceOrder recibe un repositorio de Postgres. En una prueba unitaria, recibe un fake en memoria. El código del caso de uso es idéntico en ambos casos, y nunca tuvo que saber cuál de los dos recibió.
Ejemplo práctico: realizar un pedido
Sigue una operación a través de las capas.
El controller recibe POST /orders. Analiza el cuerpo (body) para convertirlo en un objeto plano, valida que el id del cliente esté presente y que las líneas estén bien formadas, y llama al use case. No interactúa con la base de datos ni construye un Order por sí mismo.
router.post("/orders", async (req, res) => {
const result = await placeOrder.execute({
customerId: req.body.customerId,
lines: req.body.lines.map((line) => ({
sku: line.sku,
quantity: line.quantity,
unitPrice: Money.fromCents(line.unitPriceCents),
})),
});
res.status(201).json(result);
});
El use case construye la entidad con Order.place, que hace cumplir la invariante de que un pedido necesita al menos una línea. Luego llama a this.orders.save(order) — un port. Este devuelve { orderId, totalCents }, datos planos sin que se filtre ninguna entidad.
El adapter implementa save. Abre una transacción, realiza un upsert de la fila del pedido, reemplaza las líneas y hace el commit. Mapea order.lines a filas y, en findById, mapea las filas de vuelta con Order.reconstitute. Ese mapeo es tarea del adapter; el use case nunca ve una fila.
Observa qué importó el dominio: Money, que también es código de dominio. Nada más. Observa qué importó el use case: el dominio. Observa qué importó el adapter: el dominio y pg. Todas las flechas de dependencia apuntan hacia adentro, y el mapeo ocurre exactamente en el límite.
Clean Architecture no es lo mismo que DDD
Estos dos conceptos suelen mencionarse juntos, pero no son lo mismo.
Domain-Driven Design es un conjunto de ideas sobre cómo modelar un negocio complejo: entidades con identidad, value objects sin ella, aggregates como límites de consistencia, repositories para la persistencia y un lenguaje ubicuo compartido por desarrolladores y expertos del dominio. Se centra en cómo es el modelo del dominio.
Clean Architecture se centra en hacia dónde apuntan las dependencias. No dice nada sobre si necesitas aggregates, eventos de dominio o un lenguaje ubicuo.
Puedes usar uno sin el otro:
- Una aplicación con Clean Architecture y un dominio delgado y anémico sigue manteniendo la dirección de las dependencias, aunque las reglas sean débiles.
- Una aplicación DDD con entidades de TypeORM anotadas en el dominio tiene reglas ricas, pero las dependencias son incorrectas.
Ambos combinan bien, y esa combinación es a lo que la mayoría de la gente se refiere cuando habla de un “backend correctamente estructurado”. Pero si tu dominio es sencillo, adoptar la regla de dependencias sin implementar DDD por completo es un resultado perfectamente válido. Usa value objects como Money donde eviten errores reales; no introduzcas un aggregate solo porque un libro lo diga.
Onion, hexagonal y clean: tres nombres, una misma dirección
Los equipos utilizan estos nombres como si fueran patrones competidores. En realidad, son tres representaciones de la misma restricción, publicadas con años de diferencia.
- Arquitectura hexagonal (Cockburn, 2005) sitúa la aplicación en el centro con puertos propios y adaptadores a su alrededor. Su contribución distintiva es la simetría: tanto las entradas como las salidas son adaptadores.
- Arquitectura Onion (Palermo, 2008) dibuja capas concéntricas y enfatiza que el modelo de dominio reside en el núcleo y que las dependencias apuntan hacia adentro.
- Clean Architecture (Martin, 2012) define cuatro anillos y establece explícitamente la regla de dependencia, añadiendo una capa de casos de uso entre las entidades y los adaptadores.
El vocabulario varía, pero la regla es la misma. En una revisión de código, discutir sobre qué diagrama es el correcto es una pérdida de tiempo para todos. Lo que importa es si el dominio importa un framework y si la interfaz reside junto a su usuario. Si estas dos respuestas son correctas, el patrón se está aplicando.
Las lecturas no necesitan tanta ceremonia
Un error frecuente es forzar que las consultas pasen por la misma maquinaria que las escrituras. Un caso de uso existe para proteger invariantes; una consulta no tiene invariantes que proteger. Envolver un SELECT en una entidad, un caso de uso y un mapper solo añade archivos y errores de mapeo sin ningún beneficio.
Es común aplicar una división pragmática: los comandos pasan a través del dominio y los puertos, mientras que las consultas van directamente a un modelo de lectura y devuelven DTOs.
// queries/order-summary.ts
export async function getOrderSummary(pool: Pool, orderId: string) {
const { rows } = await pool.query(
`SELECT o.id, o.status, o.total_cents, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1`,
[orderId],
);
return rows[0] ?? null;
}
Esta consulta importa pg, y eso está bien. Es un adaptador de capa externa sin lógica de dominio, por lo que la regla de dependencia no tiene nada que proteger. Mantener las lecturas simples no es un compromiso; es aplicar el patrón con honestidad, porque el valor de un modelo de dominio es hacer cumplir las reglas, y una lectura no hace cumplir nada.
Esta es la semilla de CQRS, y puedes detenerte aquí. No necesitas bases de datos separadas ni proyecciones de eventos para permitir que las lecturas eviten el dominio; basta con una línea clara entre las operaciones que deciden y las operaciones que consultan.
Probando el dominio sin una base de datos
El beneficio más concreto de la regla de dependencias es la velocidad de las pruebas. Debido a que el dominio no importa nada, sus tests no necesitan nada.
Un unit test para PlaceOrder construye un repositorio fake en memoria y lo pasa como argumento. El test verifica que el pedido se haya guardado y que el total sea correcto. Se ejecuta en microsegundos y no requiere ningún contenedor, ni migraciones, ni red.
const orders = new InMemoryOrderRepository();
const useCase = new PlaceOrder(orders);
const result = await useCase.execute({
customerId: "cust_1",
lines: [{ sku: "book", quantity: 2, unitPrice: Money.fromCents(1500) }],
});
expect(result.totalCents).toBe(3000);
Los tests de las entidades son aún más sencillos. Money verifica que añadir monedas mixtas lance una excepción y que multiply escale correctamente. Order verifica que realizar un pedido vacío lance una excepción y que cancelar dos veces no cause problemas. Ninguno de ellos menciona una base de datos.
La base de datos real sigue necesitando pruebas, pero ahora el test se centra en el adapter, no en las reglas: ¿PostgresOrderRepository.save escribe las filas correctamente? y ¿findById reconstruye la entidad fielmente? Eso se traduce en un único test de integración enfocado por adapter; cuando falla, sabes que el problema está en el mapping y no en la lógica de negocio.
Esta separación es el objetivo principal. Sin ella, cada test de una regla arrastra una base de datos, por lo que los tests se vuelven lentos, inestables y poco frecuentes. Con ella, las reglas permanecen cubiertas y los tests lentos son pocos y específicos.
El precio de la indirección
La Clean Architecture no es gratuita, y fingir lo contrario lleva a que los equipos la apliquen en todas partes y terminen resintiéndola.
Más archivos. Un único endpoint que antes era un handler y una query se convierte en un controller, un use case, un port, un adapter y un mapper. Para un recurso CRUD, eso son cinco archivos donde uno bastaría, y el rastro desde la request hasta la query es más largo.
Mapeo en ambas direcciones. Las filas se convierten en entidades, las entidades en respuestas y, a veces, hay DTOs de por medio. El mapeo es aburrido, repetitivo y es fácil cometer errores sutiles: un campo faltante o una moneda que se asigna por defecto silenciosamente. Requiere sus propias pruebas, y esas pruebas no están validando valor de negocio.
Un vocabulario más amplio. Ports, adapters, use cases, composition roots, DTOs. Un desarrollador nuevo tiene que aprender la estructura antes de poder encontrar cualquier cosa, y un equipo que la adopta a medias obtiene lo peor de ambos mundos: indirección sin límites consistentes.
Una falsa sensación de desacoplamiento. Importar una interfaz no te hace independiente de la tecnología que hay detrás. Si tu port expone semántica SQL, o tu dominio depende del comportamiento de ON CONFLICT, sigues estando acoplado. La interfaz es una costura, no un campo de fuerza.
El enfoque honesto es que esto es una inversión. Se recupera cuando el dominio es lo suficientemente rico como para requerir pruebas, cuando es probable que la infraestructura cambie y cuando varias personas necesitan límites claros. No se recupera en una pantalla de configuración.
La rentabilidad es más evidente en el mantenimiento. Cuando una regla cambia, el cambio recae en una entidad y una prueba. Cuando una query necesita un índice, recae en un adapter. Cuando el equipo quiere probar un nuevo proveedor de pagos, escriben un segundo adapter y cambian una línea en el composition root. Ninguno de esos cambios repercute en los demás, y ese es todo el retorno de inversión de los archivos adicionales.
Dónde trazar la línea
La respuesta pragmática no es “todo o nada”. Traza el límite donde exista una regla y deja el resto simple.
Una heurística útil: si una operación tiene una regla de negocio que podría fallar de una manera compleja, asígnale un use case y un objeto de dominio. Realizar un pedido, aplicar un descuento, cancelar una suscripción; estos procesos tienen invariantes que vale la pena proteger. Si una operación es una lectura o escritura directa sin toma de decisiones, deja que sea una query ligera, opcionalmente detrás de un repositorio pequeño, y no la envuelvas en ceremonias innecesarias.
En el mismo sistema puedes tener ambas cosas. Un use case PlaceOrder con un aggregate Order rico y un fake en memoria puede coexistir junto a un GetProductById que sea una sola query mapeada a un DTO. Nadie sale perjudicado por la asimetría, y la base de código se mantiene proporcional al problema.
Sé igualmente pragmático con el ORM. Muchos equipos mantienen un ORM para las lecturas y utilizan adaptadores de repositorio escritos a mano para la ruta de escritura del dominio. Otros mantienen SQL puro en todas partes y aceptan que el adaptador se encargue del mapeo. La regla se trata de la dirección, no de las herramientas: mientras el dominio no importe el ORM, eres libre de usar lo que el adaptador necesite.
Vale la pena mencionar claramente el punto medio más común. Un paquete de dominio con entidades reales para los dos o tres conceptos que contienen reglas. Un use case por cada comando significativo. Interfaces de repositorio solo donde la ruta de escritura las necesite. Todo lo demás —lecturas, pantallas de administración, reportes— como handlers ligeros sobre SQL. Esto no es un compromiso; es el patrón escalado al problema.
Estructuración de un proyecto
La disposición de las carpetas debe hacer que la dirección de las dependencias sea obvia a simple vista. Una estructura común:
src/
domain/
order.ts
money.ts
order-repository.ts
application/
place-order.ts
cancel-order.ts
infrastructure/
postgres-order-repository.ts
stripe-payment-gateway.ts
interfaces/
http/
order-controller.ts
routes.ts
main.ts
Los nombres importan menos que la regla. domain no importa nada de los demás. application importa domain. infrastructure y interfaces importan ambos. main.ts los conecta y es el único archivo al que se le permite conocer cada capa.
Si prefieres los vertical slices —una carpeta por funcionalidad con domain, application y infrastructure en su interior— eso también funciona y escala bien cuando las funcionalidades son independientes. Lo que pierdes es un lugar único y obvio para buscar reglas transversales; lo que ganas es que cada funcionalidad es autónoma.
Refuerza la dirección con herramientas en lugar de basarte en la disciplina. El no-restricted-imports, dependency-cruiser de ESLint o un plugin de import-boundary pueden hacer que la build falle cuando domain importa pg. Una regla que es solo una convención es una regla que se romperá un viernes a las 5 p. m.
Cualquiera que sea la disposición que elijas, mantén el composition root explícito y pequeño. Un único main.ts que importe los adaptadores concretos y construya los casos de uso es fácil de leer y fácil de cambiar. Cuando el cableado está disperso en módulos que construyen sus propias dependencias, nadie puede decir a qué base de datos se está conectando realmente un caso de uso, y la costura que prometía la arquitectura desaparece.
Qué debe incluir un caso de uso
Un caso de uso es una única intención expresada como una clase con un solo método público: PlaceOrder, CancelOrder, RefundPayment. Su método execute recibe una entrada simple, orquesta el dominio y los puertos, y devuelve una salida simple. Si no puedes nombrar la intención como un verbo y un sustantivo, es probable que el caso de uso esté haciendo demasiado.
Un caso de uso debe contener el flujo, no las reglas. Decide el orden de los pasos: cargar, actuar, persistir, publicar. No decide qué hace que un pedido sea válido; eso reside en la entidad. Esta distinción es importante porque las reglas se reutilizan en varios casos de uso, mientras que los flujos generalmente no.
export class CancelOrder {
constructor(
private readonly orders: OrderRepository,
private readonly events: EventPublisher,
) {}
async execute(input: { orderId: string; reason: string }) {
const order = await this.orders.findById(input.orderId);
if (!order) throw new OrderNotFound(input.orderId);
order.cancel(); // the rule lives in the entity
await this.orders.save(order);
await this.events.publish("order.cancelled", { orderId: order.id });
}
}
Lo que un caso de uso no debe hacer: construir SQL, leer req o res, conocer códigos de estado HTTP, enviar correos electrónicos directamente o importar un framework. Cada una de estas tareas es una preocupación de la capa exterior o pertenece a un puerto. Si el caso de uso importa express, la frontera ha fallado, independientemente de cómo se llamen las carpetas.
La autorización es una cuestión de diseño real. Verificar los permisos en el caso de uso mantiene las reglas en un solo lugar y las hace testeables; verificarlos en el middleware implica menos código pero es más fácil olvidarlo. Una respuesta viable es realizar verificaciones generales en el borde y autorización a nivel de negocio dentro del caso de uso, porque “solo el propietario puede cancelar” es una regla, no una preocupación de enrutamiento.
Transacciones, efectos secundarios y el límite
Las transacciones son una preocupación de infraestructura, pero su límite es una preocupación de la aplicación. El caso de uso sabe que un conjunto de cambios debe confirmarse en conjunto; no debería saber que el mecanismo es BEGIN y COMMIT en Postgres.
Hay dos enfoques que funcionan bien. El más sencillo es hacer que cada método del repositorio sea transaccional por sí mismo, lo cual es adecuado cuando un caso de uso realiza una sola escritura. Cuando un caso de uso escribe a través de varios repositorios y los cambios deben ser atómicos, introduce un puerto de unit of work:
export interface UnitOfWork {
run<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
El adaptador lo implementa con una conexión y una transacción, y el caso de uso envuelve su trabajo en unitOfWork.run. El dominio sigue siendo el dueño de la interfaz, el adaptador es el dueño del SQL, y la atomicidad es explícita en la capa de aplicación, que es donde corresponde.
Los efectos secundarios siguen la misma regla. Enviar un correo electrónico, realizar un cargo a una tarjeta o publicar un evento debería ser un puerto — EmailSender, PaymentGateway, EventPublisher — y no una llamada directa a fetch. Esto mantiene el caso de uso testeable, ya que el fake registra lo que se habría enviado, y mantiene la elección del proveedor en el adaptador.
El orden de los efectos secundarios respecto a la base de datos es sutil. Un caso de uso que guarda un pedido y luego publica un evento tiene el problema de la escritura doble (dual-write): el proceso puede morir entre ambas acciones. El patrón fiable es escribir el evento en la misma transacción que el estado — un outbox — y dejar que un relay lo publique. El caso de uso le pide a un EventPublisher que registre el evento; el adaptador decide si eso significa una fila en el outbox o una publicación directa.
Dominio anémico, abstracciones filtradas y tipos de framework
Existen tres modos de fallo que parecen Clean Architecture, pero no lo son.
Un dominio anémico es un conjunto de clases que solo contienen campos, getters y setters, mientras que toda la lógica reside en los servicios. Las carpetas son correctas, las flechas de dependencia son correctas y el dominio está vacío. No es un desastre —una capa de servicio sobre datos planos es un diseño legítimo— pero llamarlo un dominio rico es engañarse a uno mismo, y generalmente significa que las invariantes se aplican en varios lugares y se olvidan en alguno.
Una abstracción filtrada (leaky abstraction) es un puerto moldeado por su implementación. OrderRepository.upsertOnConflict menciona Postgres. PaymentGateway.chargeWithStripeToken menciona un proveedor. Un puerto debería hablar el lenguaje de la aplicación: save, findById, charge. Cuando un puerto filtra detalles, cambiar el adaptador implica cambiar la interfaz y cada llamador, lo que anula el propósito de tener un puerto.
Los tipos de framework en el dominio son la violación más directa. Una entidad anotada con @Entity y @Column, o un caso de uso cuyo tipo de entrada es un Request de Express, ha importado una capa externa. Puede que compile y pase las pruebas, pero la regla de dependencia se ha roto, y ahora el framework decide cuándo y cómo se construye el dominio. Mantén los decoradores y los tipos de petición en las capas externas y pasa objetos planos hacia el interior.
Estrategia de testing de adentro hacia afuera
La regla de dependencias hace que la pirámide de tests surja de forma natural. Haz el testing desde el interior, donde los tests son rápidos, y avanza hacia afuera solo lo necesario.
Los tests de dominio cubren las entidades y los value objects. Son unit tests puros sin I/O, y debe haber muchos de ellos, ya que las reglas son donde los bugs resultan más costosos. Money rechaza monedas mixtas; Order rechaza una lista de líneas vacía; cancelar dos veces no produce ningún efecto (no-op).
Los tests de casos de uso cubren el flujo. Construye el caso de uso con fakes en memoria para cada puerto, llama a execute y verifica el resultado y lo que los fakes registraron. Estos detectan pasos faltantes, orden incorrecto y guardados olvidados, y aun así se ejecutan en microsegundos.
Los tests de adaptadores cubren el mapeo. Ejecútalos contra un PostgreSQL real en un contenedor, inserta y lee los datos, y verifica que la entidad realice el viaje de ida y vuelta (round-trip) fielmente. Aquí es donde descubres que status regresó como un string o que una columna nullable produjo una línea undefined. Normalmente, un test enfocado por cada método del adaptador es suficiente.
Los tests end-to-end cubren el cableado: una solicitud HTTP real a través del controlador, el caso de uso y la base de datos, verificando la respuesta. Mantén pocos de estos. Son lentos, fallan por razones no relacionadas y su función es demostrar que la raíz de composición está conectada, no volver a testear las reglas.
Esta división significa que un test de regla fallido apunta al dominio, un test de flujo fallido apunta al caso de uso, un test de round-trip fallido apunta al adaptador y un test end-to-end fallido apunta al cableado. Esa claridad diagnóstica vale más que la cantidad bruta de tests.
Cuándo introducir la frontera
Rara vez se aciertan las fronteras al principio, porque aún no sabes dónde están las reglas. Una secuencia práctica es comenzar con handlers ligeros y un módulo de acceso a datos, observar dónde se acumula la lógica y, en ese momento, extraer un caso de uso y un objeto de dominio.
Señales de que vale la pena introducir una frontera:
- La misma regla se aplica en más de un handler.
- Una prueba de una regla de negocio requiere una base de datos o un servidor en ejecución.
- Cambiar un framework o un ORM requeriría modificar la lógica.
- Una función mezcla validación, persistencia y llamadas externas, y es difícil de nombrar.
- Dos desarrolladores chocan constantemente en el mismo archivo.
Señales de que no deberías molestarte:
- La operación es una lectura o escritura directa sin toma de decisiones.
- Las reglas aún cambian a diario y estabilizarlas sería prematuro.
- Toda la aplicación es gestionada por una sola persona y tiene unos pocos endpoints.
Introduce la frontera donde esté el problema, no en todas partes a la vez. Una base de código con tres fronteras bien definidas y mucho código simple es más saludable que una donde cada tabla tiene un agregado y cada llamada tiene un puerto.
Mejores prácticas
- Mantén la flecha de dependencia apuntando hacia adentro y haz que el dominio no importe nada.
- Define los ports donde se utilizan, en la capa interna, no donde se implementan.
- Diseña los ports basándote en las necesidades de la aplicación, no en las capacidades de la base de datos.
- Coloca las implementaciones concretas en los adapters y selecciónalas únicamente en el composition root.
- Realiza el mapeo entre filas, objetos de dominio y DTOs en los límites; nunca permitas que los tipos de una capa se filtren a otra.
- Asigna una sola intención a cada caso de uso y haz que devuelva datos simples.
- Aplica los invariantes en el dominio mediante factories y constructores privados, no en los controladores.
- Realiza unit-tests de los casos de uso con fakes en memoria y integration-tests de los adapters por separado.
- Mantén el dominio libre de decoradores de frameworks, anotaciones de ORM y tipos de HTTP.
- Aplica el patrón donde existan reglas y mantén las operaciones CRUD simples y ligeras.
- Fuerza los límites de importación con una regla de lint o dependency-cruiser en el CI.
Errores comunes
- Anotar entidades de dominio con decoradores de ORM y llamarlo Clean Architecture.
- Retornar entidades o filas de la base de datos directamente a los controladores.
- Definir la interfaz del repositorio junto a la clase de Postgres en lugar de junto al caso de uso.
- Escribir un dominio anémico de getters y setters manteniendo la lógica en el servicio.
- Filtrar semántica de SQL hacia un puerto y creer que las capas están desacopladas.
- Abstraer cada tabla y llamada, convirtiendo una app CRUD en pura ceremonia.
- Colocar el composition root en todas partes, haciendo que nada esté realmente invertido.
- Probar casos de uso contra la base de datos real y perder la velocidad que justificaba la separación.
- Omitir el mapeo y pasar
anya través de un límite de capa. - Adoptar DDD, CQRS y event sourcing completos a la vez solo porque un diagrama lo sugería.
Próximos pasos
Clean Architecture traza límites dentro de una sola aplicación. La guía de Modular Monolith muestra cómo hacer que esos límites sean explícitos entre módulos sin pagar el costo de una red, lo cual es el siguiente paso adecuado para la mayoría de los sistemas. Cuando un límite realmente necesita convertirse en una unidad desplegable, la guía de Microservices cubre de dónde provienen los bordes del servicio y qué cambia cuando una llamada se vuelve remota. Si quieres que los puertos sean económicos y se documenten solos, la guía de TypeScript cubre interfaces y tipos, y PostgreSQL es la base de datos para la cual se escriben la mayoría de los adapters.